Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 24 additions & 16 deletions crates/utopia-server/src/phrase_alignment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,27 @@ pub async fn align_phrases_reasking(
result
}

/// 对齐收尾:没绑上的形状够多,叫本体代理来看(0061 决定 2)。代理自己跳过提过的,
/// 所以这里只数不筛;一分钟的去抖让连着几篇文档只叫一次。有属性没属性都从这里过——
/// 空本体的签名全判「没有」,正是代理该来的时候
async fn queue_ontology_agent_if_unbound(pool: &sqlx::PgPool, kb_id: Uuid) -> anyhow::Result<()> {
let unbound = phrase_bindings::bindings(pool, kb_id)
.await?
.iter()
.filter(|b| matches!(b.status.as_str(), "none" | "undecided"))
.count();
if unbound >= crate::ontology_agent::TRIGGER_UNBOUND {
utopia_store::jobs::enqueue_unless_pending(
pool,
"propose_ontology",
serde_json::json!({ "kb_id": kb_id }),
std::time::Duration::from_secs(60),
)
.await?;
}
Ok(())
}

async fn align_phrases_locked(
state: &AppState,
kb_id: Uuid,
Expand Down Expand Up @@ -813,6 +834,8 @@ async fn align_phrases_locked(
)
.await?;
}
// 空本体正是需要本体代理的时候:照样数未绑签名、够了就排它,本体才长得出来
queue_ontology_agent_if_unbound(pool, kb_id).await?;
return Ok(());
}

Expand Down Expand Up @@ -1429,23 +1452,8 @@ async fn align_phrases_locked(
// 漏答的签名就只能等下一篇文档来排——最后一篇之后没有下一篇,它们就永远没有结论;
// 而漏答不写任何行,审核队列也看不见(同类别词对齐)
let unfinished = failed > 0 || unanswered > 0;
// 对齐收尾:没绑上的形状够多,叫本体代理来看(0061 决定 2)。代理自己跳过提过的,
// 所以这里只数不筛;一分钟的去抖让连着几篇文档只叫一次
if !changed && !unfinished {
let unbound = phrase_bindings::bindings(pool, kb_id)
.await?
.iter()
.filter(|b| matches!(b.status.as_str(), "none" | "undecided"))
.count();
if unbound >= crate::ontology_agent::TRIGGER_UNBOUND {
utopia_store::jobs::enqueue_unless_pending(
pool,
"propose_ontology",
serde_json::json!({ "kb_id": kb_id }),
std::time::Duration::from_secs(60),
)
.await?;
}
queue_ontology_agent_if_unbound(pool, kb_id).await?;
}
if changed {
utopia_store::jobs::enqueue_unless_queued(
Expand Down
50 changes: 50 additions & 0 deletions crates/utopia-server/src/phrase_alignment_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1469,3 +1469,53 @@ async fn asking_what_a_date_marks_never_reopens_the_binding() -> anyhow::Result<
f.cleanup().await?;
run
}

#[tokio::test]
async fn a_base_with_no_properties_queues_propose_ontology_once_twenty_signatures_are_unbound(
) -> anyhow::Result<()> {
let Some(f) = Fx::new().await? else {
return Ok(());
};
let run = async {
sqlx::query("DELETE FROM relation_type_domains WHERE relation_type_id=$1")
.bind(f.based_in)
.execute(&f.pool)
.await?;
sqlx::query("DELETE FROM relation_type_ranges WHERE relation_type_id=$1")
.bind(f.based_in)
.execute(&f.pool)
.await?;
sqlx::query("DELETE FROM relation_types WHERE kb_id=$1")
.bind(f.kb)
.execute(&f.pool)
.await?;
for n in 0..crate::ontology_agent::TRIGGER_UNBOUND {
let place = Uuid::now_v7();
sqlx::query("INSERT INTO entities(id,kb_id,canonical_name,type_id) VALUES($1,$2,$3,$4)")
.bind(place)
.bind(f.kb)
.bind(format!("Place {n}"))
.bind(f.place)
.execute(&f.pool)
.await?;
sqlx::query("INSERT INTO facts(id,kb_id,subject_id,object_id,layer,phrase) VALUES($1,$2,$3,$4,'open',$5)")
.bind(Uuid::now_v7())
.bind(f.kb)
.bind(f.acme)
.bind(place)
.bind(format!("phrase {n}"))
.execute(&f.pool)
.await?;
}
f.run().await?;
let queued = f.queued().await?;
anyhow::ensure!(
queued.contains(&"propose_ontology".to_string()),
"propose_ontology not queued, queued: {queued:?}"
);
anyhow::Ok(())
}
.await;
f.cleanup().await?;
run
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 0061 · The ontology is proposed from the open graph and judged by its questions

- **Status**: In progress · accepted 2026-09-26 · cuts 1 and 1.1 built (#947, #950), cut 3 slices 1–2 (#955, #962) · revised 2026-10-16 (#1257) · revised 2026-10-17 (#1288) · open: cut 2, the rest of cut 3, cut 4
- **Status**: In progress · accepted 2026-09-26 · cuts 1 and 1.1 built (#947, #950), cut 3 slices 1–2 (#955, #962) · revised 2026-10-16 (#1257) · revised 2026-10-17 (#1288) · revised 2026-10-20 (#1336) · open: cut 2, the rest of cut 3, cut 4
- **Written**: 2026-09-26 (conventions in the [README](README.md))
- **Related**: [0044](0044-the-ontology-is-a-view-over-what-documents-say.md) §4 decided the workbench and named this cut; [0003](0003-ontology-growth-loop.md) grew the ontology from `ontology_misses` and surface predicates, which [0044](0044-the-ontology-is-a-view-over-what-documents-say.md) cut 1 left idle and #725 retires; [0012](0012-the-ontology-is-a-contract-not-a-suggestion.md) makes every element a contract a person signed, which this record keeps; [0053](0053-a-phrase-decision-records-the-inputs-it-considered.md) is what makes approval re-decide signatures without a new mechanism; [0008](0008-ontology-packs-as-cold-start.md) is the second of the three sources; [0043](0043-every-review-queue-is-governed.md) and #725 give the queue its shape. From #725's "Ontology proposals" row and 0044's open question about a base with no questions.

Expand All @@ -22,6 +22,8 @@ The open graph holds statements with their phrases, quotes, kind words and names

**Revision 2026-10-17 (a reuse keeps what a person decided after the agent read it, #1288):** decision 3's "A signature a person decided stays as the person left it" did not say against what. A reuse wrote every shape and kind word it named as the adopter's binding, overwriting a binding a colleague decided after the agent read it, which neither the proposal nor the adopter had seen. For a reuse, the decision the agent read is what the proposal answers and is replaced; a person's decision made after that read stays. The proposal records when each decision it read was made. A person's later decision that binds the way the proposal does counts as written, and a shape keeps its regression case; one that differs is left as it is, gets no case and is listed in the answer. A reuse with nothing left to write is refused. A reuse saved before this revision recorded no reads, so adopting it keeps every person's decision.

**Revision 2026-10-20 (an empty ontology still calls the agent, #1336):** decision 2 says the job runs after alignment settles when unbound signatures pass the threshold, but phrase alignment returned before that trigger when the base had no properties: every signature was decided `none` as `no_properties` and the agent was never queued. A base created without packs or an import, which 0009 names as a supported path, could grow its ontology only from the workbench's Suggest. The empty-ontology branch now counts unbound signatures and queues the agent the same way the normal tail does; the trigger is one function for both.

**4. Every approved element carries regression cases.** The example statements that motivated a proposal become `ontology_regression_cases` on adoption: a statement and the property or class it is expected to bind to. A later edit to the element's definition, domains or ranges reruns alignment on exactly those signatures and records which cases still bind. A failing case is shown on the workbench beside the edit; it is not reverted for the person, because a definition may be tightened on purpose. A person can add a case from any statement ("this should bind here"), which is the "make a rule" action #725 names.

**5. Two numbers measure the ontology.** Questions answered correctly: each accepted question is asked over the typed graph the way a person would ask it in chat, and the answer is judged against the expected answer where one is written, or against whether the shape it needs exists and holds facts. Proposals people change: the share of the agent's proposals rejected or edited before adoption. Both are reported per base and by the bench 0044's table names, FDA and statistics corpora with written questions.
Expand Down
Loading