Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
08dbeaa
A retraction needs a flag and two votes; an addition may name a new t…
WaylandYang Sep 24, 2026
3fe7096
A workspace can tell its chat model how hard to think, and the bench …
WaylandYang Sep 24, 2026
2f85de7
The alignment prompt describes each candidate once per batch, and rul…
WaylandYang Sep 24, 2026
e6dcf8d
The aligner shortlists a signature's candidates by relevance when the…
WaylandYang Sep 24, 2026
319644d
The rule prompt shares its glossary, kind words get a shortlist too, …
WaylandYang Sep 24, 2026
bf9e9fc
Alignment and rule proposals think at the endpoint's default effort; …
WaylandYang Sep 24, 2026
60e148a
An ontology embedding job that lost a batch fails so it retries inste…
WaylandYang Sep 24, 2026
1978360
A label the model answers instead of a key still names the property w…
WaylandYang Sep 24, 2026
1bd23cc
Alignment batches, rule proposals and the bench judge run four at a t…
WaylandYang Sep 24, 2026
4154c03
Log the head of an alignment reply that carried malformed votes
WaylandYang Sep 24, 2026
2d335bf
Errata sees the properties nearest the document, and rule proposals t…
WaylandYang Sep 24, 2026
bc05734
Drop a needless borrow the parallel batches left behind
WaylandYang Sep 24, 2026
b277e71
Alignment thinks at low effort, a measured middle between off and the…
WaylandYang Sep 24, 2026
e15ecd8
Keep the malformed-vote log reachable and the extract test helper who…
WaylandYang Sep 24, 2026
55e5cc5
Record the second measured runs: where the tokens went and what each …
WaylandYang Sep 24, 2026
f6e40a8
Pick the reasoning effort with the Dropdown control, as the design ru…
WaylandYang Sep 25, 2026
bb338ae
Merge dev into feat/errata-two-votes; the schema version counts 0075,…
WaylandYang Sep 25, 2026
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
2 changes: 1 addition & 1 deletion crates/utopia-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ struct ManifestDataDir {
/// not a side effect of a code change.
// 是迁移文件的**个数**,不是最大的编号(守卫 `schema_version_policy_compares_against_current`
// 按个数比):编号有空缺时两者不同——0071 由一个开放 PR 占着,0072 先落,个数是 71
const CURRENT_SCHEMA_VERSION: u32 = 77;
const CURRENT_SCHEMA_VERSION: u32 = 78;

fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
Expand Down
3 changes: 3 additions & 0 deletions crates/utopia-core/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,9 @@ pub struct LlmSettings {
#[serde(skip_serializing)]
pub transcribe_api_key: Option<String>,
pub transcribe_model: Option<String>,
/// 对话模型的推理强度(OpenAI 兼容口的 `reasoning_effort`):minimal | low | medium | high;
/// 空 = 不带字段。照原文写 JSON 的任务用 minimal,思考 token 归零、答案不变
pub chat_reasoning_effort: Option<String>,
}

impl LlmSettings {
Expand Down
76 changes: 75 additions & 1 deletion crates/utopia-extract/src/errata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,11 @@ pub fn build_errata_messages(
.map(property_line)
.collect::<Vec<_>>()
.join("\n");
let list = facts.iter().map(fact_line).collect::<Vec<_>>().join("\n");
let mut list = facts.iter().map(fact_line).collect::<Vec<_>>().join("\n");
if list.is_empty() {
// 没有类型化事实的文档也送去看:清单为空,agent 只能加
list.push_str("(none yet: add what the document states plainly)");
}
vec![
ChatMessage {
role: "system".into(),
Expand Down Expand Up @@ -243,6 +247,58 @@ pub fn parse_errata_response(
Ok(out)
}

/// 第二票(撤改要两票):agent 说撤或改的那几条再问一遍,只问「文档说了没有」。
/// 第一票带着整份清单和结构的理由,容易顺着理由撤掉原文其实说了的事;第二票只看事实与原文
pub const CONFIRM_SYSTEM: &str = "You check facts against ONE document. For each numbered fact answer whether the document states it, \
in its own words or in other words with the same meaning. Answer stated when the document supports the fact even if a name is \
abbreviated or the wording differs; answer not_stated only when the document does not say it or says otherwise. \
Judge only from the document. Answer with JSON only: {\"c\":[[id,\"stated\"|\"not_stated\"]]}";

pub fn build_confirm_messages(document: &str, facts: &[ErrataFact<'_>]) -> Vec<ChatMessage> {
let list = facts
.iter()
.map(|f| format!("{}: {} —{}→ {}", f.id, f.subject, f.property, f.object))
.collect::<Vec<_>>()
.join("\n");
vec![
ChatMessage {
role: "system".into(),
content: CONFIRM_SYSTEM.to_string(),
},
ChatMessage {
role: "user".into(),
content: format!("DOCUMENT:\n{document}\n\nFACTS:\n{list}"),
},
]
}

/// 第二票的回复:哪些 id 被判 not_stated。没答到的、答成别的都不算票——撤要两票齐,缺一票就留着
pub fn parse_confirm_response(
raw: &str,
ids: &[i64],
) -> Result<std::collections::HashSet<i64>, String> {
let v: serde_json::Value =
serde_json::from_str(extract_json(raw)).map_err(|e| e.to_string())?;
let rows = v
.get("c")
.and_then(|x| x.as_array())
.ok_or_else(|| "no \"c\" array".to_string())?;
let mut not_stated = std::collections::HashSet::new();
for row in rows {
let Some(arr) = row.as_array() else { continue };
let (Some(id), Some(verdict)) = (
arr.first().and_then(|x| x.as_i64()),
arr.get(1).and_then(|x| x.as_str()),
) else {
continue;
};
if ids.contains(&id) && verdict == "not_stated" {
not_stated.insert(id);
}
}
Ok(not_stated)
}

fn extract_json(raw: &str) -> &str {
match (raw.find('{'), raw.rfind('}')) {
(Some(a), Some(b)) if b > a => &raw[a..=b],
Expand Down Expand Up @@ -343,6 +399,24 @@ mod tests {
assert_eq!(r.malformed, 1);
}

#[test]
fn the_second_vote_only_counts_an_explicit_not_stated() {
let m = build_confirm_messages("Acme is based in London.", &facts()[..1]);
assert!(m[1].content.contains("0: Acme —based_in→ Paris"));
let ids = [0, 1];
let r = parse_confirm_response(
r#"{"c":[[0,"not_stated"],[1,"stated"],[7,"not_stated"],[2]]}"#,
&ids,
)
.unwrap();
assert!(r.contains(&0) && !r.contains(&1) && !r.contains(&7));
// 没答到的不算票
assert!(parse_confirm_response(r#"{"c":[]}"#, &ids)
.unwrap()
.is_empty());
assert!(parse_confirm_response("nope", &ids).is_err());
}

#[test]
fn no_array_is_an_error_not_an_empty_answer() {
assert!(parse_errata_response("{}", &facts()).is_err());
Expand Down
48 changes: 44 additions & 4 deletions crates/utopia-extract/src/implication.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,22 @@ fn readings_text(readings: &[(&str, &str)]) -> String {
}

pub fn build_rule_messages(items: &[RuleItem<'_>], readings: &[(&str, &str)]) -> Vec<ChatMessage> {
// 候选表一批只写一遍,各项只列键(与 `phrase_align::build_phrase_messages` 同一条理由:
// 每项带整张表时一次请求五万多 token)
let mut glossary: Vec<String> = Vec::new();
let mut seen = std::collections::HashSet::new();
for c in items.iter().flat_map(|i| i.candidates.iter()) {
if seen.insert(c.key.trim()) {
glossary.push(candidate_line(&PropertyCandidate {
via: Vec::new(),
..c.clone()
}));
}
}
let mut user = String::new();
if !glossary.is_empty() {
user.push_str(&format!("Properties:\n{}\n\n", glossary.join("\n")));
}
for item in items {
let shape = if item.trigger == "kind_word" {
format!("kind word \"{}\"", item.phrase)
Expand Down Expand Up @@ -92,8 +107,8 @@ pub fn build_rule_messages(items: &[RuleItem<'_>], readings: &[(&str, &str)]) ->
let candidates = if item.candidates.is_empty() {
" (none)".to_string()
} else {
let lines: Vec<String> = item.candidates.iter().map(candidate_line).collect();
format!("\n{}", lines.join("\n"))
let keys: Vec<&str> = item.candidates.iter().map(|c| c.key.trim()).collect();
format!(" {}", keys.join(", "))
};
user.push_str(&format!(
"Item {}: {shape}{bound}\nExamples:{examples}\nCandidates:{candidates}\n\n",
Expand Down Expand Up @@ -141,8 +156,9 @@ pub fn parse_rule_response(
let reading = arr.get(2).and_then(|x| x.as_str());
let implies = match key {
None => None,
Some(k) => {
if !item.candidates.iter().any(|c| c.key == k) || item.bound_to == Some(k) {
Some(written) => {
let k = resolve_key(item, written)?;
if item.bound_to == Some(k) {
return None;
}
if let Some(r) = reading {
Expand Down Expand Up @@ -281,6 +297,30 @@ pub fn parse_reading_response(
}

/// 回复里可能裹着 ```json 围栏或前后的话:取第一个 { 到最后一个 }
/// 模型写的键:候选里的键(大小写不论),或唯一对上的标签——键是 `p569` 这种代号时模型
/// 常答标签(与 `phrase_align::candidate_key` 同一条理由)
fn resolve_key<'a>(item: &RuleItem<'a>, written: &str) -> Option<&'a str> {
let written = written.trim();
if let Some(c) = item.candidates.iter().find(|c| c.key.trim() == written) {
return Some(c.key);
}
let lower = written.to_lowercase();
let mut by_key = item
.candidates
.iter()
.filter(|c| c.key.trim().to_lowercase() == lower);
if let Some(c) = by_key.next() {
return by_key.next().is_none().then_some(c.key);
}
let folded = crate::phrase_align::fold(written);
let mut by_label = item
.candidates
.iter()
.filter(|c| crate::phrase_align::fold(c.label) == folded);
let c = by_label.next()?;
by_label.next().is_none().then_some(c.key)
}

fn extract_json(raw: &str) -> &str {
match (raw.find('{'), raw.rfind('}')) {
(Some(a), Some(b)) if b > a => &raw[a..=b],
Expand Down
Loading