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
151 changes: 149 additions & 2 deletions agents/drafter_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,15 +171,162 @@ def traceability_check_node(
f"[DISCLOSURE_TEXT]\n{state['disclosure_text']}\n\n"
f"[DRAFTED_CLAIMS]\n{state['claims']}"
)
result = agent.run_structured(prompt=prompt, output_model=ClaimTraceabilityReport)
try:
result = agent.run_structured(prompt=prompt, output_model=ClaimTraceabilityReport)
repaired = _repair_traceability_min_fields(result.model_dump(), state.get("claims"))
except Exception as exc: # noqa: BLE001 - keep workflow alive on half-structured model outputs.
repaired = _build_minimal_traceability_report(state.get("claims"), str(exc))
return {
"claim_traceability": result.model_dump(),
"claim_traceability": repaired,
"current_step": "traceability_check_node",
"status": "running",
"node_latency_ms": _duration_ms(started_at),
}


def _repair_traceability_min_fields(
report_payload: dict[str, Any],
claims_payload: dict[str, Any] | None,
) -> dict[str, Any]:
"""
Secondary structure repair for critical traceability output.
Ensures each claim has at least one evidence row and fills missing claims.
"""
claim_rows = _extract_claim_rows_for_traceability(claims_payload)
existing_reports = report_payload.get("reports")
reports_by_no: dict[int, dict[str, Any]] = {}
if isinstance(existing_reports, list):
for row in existing_reports:
if not isinstance(row, dict):
continue
try:
no = int(row.get("claim_number", 0))
except Exception:
continue
if no > 0:
reports_by_no[no] = row

repaired_reports: list[dict[str, Any]] = []
for claim in claim_rows:
claim_no = int(claim["claim_number"])
current = reports_by_no.get(claim_no, {})
evidence = current.get("elements_evidence")
evidence_items: list[dict[str, Any]] = []
if isinstance(evidence, list):
for item in evidence:
if isinstance(item, dict):
feature_text = str(item.get("feature_text", "")).strip()
verbatim_quote = str(item.get("verbatim_quote", "")).strip()
support_level = str(item.get("support_level", "")).strip() or "Unsupported"
reasoning = str(item.get("reasoning", "")).strip()
if feature_text:
evidence_items.append(
{
"feature_text": feature_text,
"verbatim_quote": verbatim_quote or "原文支持片段待人工复核。",
"support_level": support_level,
"reasoning": reasoning or "模型输出不完整,已进入最小字段补全。",
}
)
if not evidence_items:
fallback_elements = claim["elements"] or [f"权利要求{claim_no}整体技术特征"]
evidence_items = [
{
"feature_text": str(fallback_elements[0])[:200],
"verbatim_quote": "原文支持片段待人工复核。",
"support_level": "Unsupported",
"reasoning": "模型返回半结构内容,系统已自动补全最小可用溯源结构。",
}
]

is_fully_supported = bool(current.get("is_fully_supported", False))
repaired_reports.append(
{
"claim_number": claim_no,
"elements_evidence": evidence_items,
"is_fully_supported": is_fully_supported,
}
)

risk = str(report_payload.get("overall_risk_assessment", "")).strip()
if len(risk) < 20:
risk = "模型输出存在结构缺口,已执行最小字段补全,请人工优先复核Unsupported项。"

return ClaimTraceabilityReport.model_validate(
{
"reports": repaired_reports if repaired_reports else _build_minimal_traceability_report(claims_payload, None)["reports"],
"overall_risk_assessment": risk,
}
).model_dump()


def _build_minimal_traceability_report(
claims_payload: dict[str, Any] | None,
failure_message: str | None,
) -> dict[str, Any]:
"""Deterministic fallback when traceability model output cannot be recovered."""
claim_rows = _extract_claim_rows_for_traceability(claims_payload)
reports: list[dict[str, Any]] = []
for claim in claim_rows:
claim_no = int(claim["claim_number"])
feature = claim["elements"][0] if claim["elements"] else f"权利要求{claim_no}整体技术特征"
reports.append(
{
"claim_number": claim_no,
"elements_evidence": [
{
"feature_text": str(feature)[:200],
"verbatim_quote": "原文支持片段待人工复核。",
"support_level": "Unsupported",
"reasoning": "模型输出解析失败,系统已生成最小可用溯源结构以避免流程中断。",
}
],
"is_fully_supported": False,
}
)
detail = (failure_message or "unknown_error").strip()
if len(detail) > 120:
detail = detail[:120]
risk = f"自动回退:traceability结构化输出失败({detail}),请人工复核全部权利要求。"
return ClaimTraceabilityReport.model_validate(
{
"reports": reports or [{"claim_number": 1, "elements_evidence": [{"feature_text": "权利要求整体技术特征", "verbatim_quote": "原文支持片段待人工复核。", "support_level": "Unsupported", "reasoning": "模型输出解析失败,系统已生成最小可用溯源结构以避免流程中断。"}], "is_fully_supported": False}],
"overall_risk_assessment": risk,
}
).model_dump()


def _extract_claim_rows_for_traceability(claims_payload: dict[str, Any] | None) -> list[dict[str, Any]]:
"""
Extract claim_number/elements rows with best effort.
Falls back to a synthetic claim row to satisfy downstream schema.
"""
rows: list[dict[str, Any]] = []
if isinstance(claims_payload, dict):
claims = claims_payload.get("claims")
if isinstance(claims, list):
for item in claims:
if not isinstance(item, dict):
continue
try:
claim_no = int(item.get("claim_number", 0))
except Exception:
continue
if claim_no <= 0:
continue
raw_elements = item.get("elements")
elements: list[str] = []
if isinstance(raw_elements, list):
for element in raw_elements:
text = str(element).strip()
if text:
elements.append(text)
rows.append({"claim_number": claim_no, "elements": elements})
if rows:
return rows
return [{"claim_number": 1, "elements": ["权利要求整体技术特征"]}]


def revise_claims_node(
state: DraftingState,
agent: BaseStructuredAgent[ClaimsSetRevision],
Expand Down
146 changes: 146 additions & 0 deletions frontend/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,150 @@ fn read_input_json(input_arg: &str) -> Result<Value, String> {
}
}

fn value_at_path<'a>(root: &'a Value, key: &str) -> Option<&'a Value> {
root.get(key)
}

fn ensure_nonempty_string_field(root: &Value, key: &str, required: bool) -> Result<(), String> {
match value_at_path(root, key) {
Some(Value::String(s)) if !s.trim().is_empty() => Ok(()),
Some(Value::Null) if !required => Ok(()),
None if !required => Ok(()),
Some(_) => Err(format!("Field '{key}' must be a non-empty string")),
None => Err(format!("Missing required field '{key}'")),
}
}

fn ensure_array_of_nonempty_strings_field(root: &Value, key: &str, required_min: usize) -> Result<(), String> {
let value = value_at_path(root, key).ok_or_else(|| format!("Missing required field '{key}'"))?;
let arr = value
.as_array()
.ok_or_else(|| format!("Field '{key}' must be an array of non-empty strings"))?;
if arr.len() < required_min {
return Err(format!(
"Field '{key}' must contain at least {required_min} item(s)"
));
}
for (idx, item) in arr.iter().enumerate() {
match item.as_str() {
Some(s) if !s.trim().is_empty() => {}
_ => return Err(format!("Field '{key}[{idx}]' must be a non-empty string")),
}
}
Ok(())
}

fn ensure_object_field(root: &Value, key: &str, required: bool) -> Result<(), String> {
match value_at_path(root, key) {
Some(Value::Object(_)) => Ok(()),
Some(Value::Null) if !required => Ok(()),
None if !required => Ok(()),
Some(_) => Err(format!("Field '{key}' must be a JSON object")),
None => Err(format!("Missing required field '{key}'")),
}
}

fn validate_cli_input_schema(workflow: &str, payload: &Value) -> Result<(), String> {
if !payload.is_object() {
return Err(String::from("Request payload must be a JSON object"));
}

match workflow {
"draft" => {
ensure_nonempty_string_field(payload, "idempotency_key", true)?;
let disclosure_text_ok = value_at_path(payload, "disclosure_text")
.and_then(Value::as_str)
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
let disclosure_file_ok = value_at_path(payload, "disclosure_file_id")
.and_then(Value::as_str)
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
if !disclosure_text_ok && !disclosure_file_ok {
return Err(String::from(
"Draft input requires either 'disclosure_text' or 'disclosure_file_id'",
));
}
if value_at_path(payload, "metadata").is_some() {
ensure_object_field(payload, "metadata", false)?;
}
}
"oa" => {
ensure_nonempty_string_field(payload, "idempotency_key", true)?;
let oa_text_ok = value_at_path(payload, "oa_text")
.and_then(Value::as_str)
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
let oa_file_ok = value_at_path(payload, "oa_notice_file_id")
.and_then(Value::as_str)
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
if !oa_text_ok && !oa_file_ok {
return Err(String::from("OA input requires either 'oa_text' or 'oa_notice_file_id'"));
}
if value_at_path(payload, "application_file_id").is_some() {
ensure_nonempty_string_field(payload, "application_file_id", false)?;
}
if value_at_path(payload, "prior_art_file_ids").is_some() {
ensure_array_of_nonempty_strings_field(payload, "prior_art_file_ids", 0)?;
}
if value_at_path(payload, "original_claims").is_some() {
ensure_object_field(payload, "original_claims", false)?;
}
if value_at_path(payload, "metadata").is_some() {
ensure_object_field(payload, "metadata", false)?;
}
}
"compare" => {
if value_at_path(payload, "idempotency_key").is_some() {
ensure_nonempty_string_field(payload, "idempotency_key", false)?;
}
if value_at_path(payload, "comparison_goal").is_some() {
ensure_nonempty_string_field(payload, "comparison_goal", false)?;
}
let app_file_ok = value_at_path(payload, "application_file_id")
.and_then(Value::as_str)
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
let prior_file_count = value_at_path(payload, "prior_art_file_ids")
.and_then(Value::as_array)
.map(|arr| arr.iter().filter(|v| v.as_str().map(|s| !s.trim().is_empty()).unwrap_or(false)).count())
.unwrap_or(0);
let prior_path_count = value_at_path(payload, "prior_arts_paths")
.and_then(Value::as_array)
.map(|arr| arr.iter().filter(|v| v.as_str().map(|s| !s.trim().is_empty()).unwrap_or(false)).count())
.unwrap_or(0);
if !app_file_ok && prior_file_count == 0 && prior_path_count == 0 {
return Err(String::from(
"Compare input should provide file IDs/paths, e.g. 'application_file_id' + 'prior_art_file_ids'",
));
}
}
"polish" => {
if value_at_path(payload, "idempotency_key").is_some() {
ensure_nonempty_string_field(payload, "idempotency_key", false)?;
}
let app_file_ok = value_at_path(payload, "application_file_id")
.and_then(Value::as_str)
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
let claims_obj_ok = value_at_path(payload, "original_claims")
.map(Value::is_object)
.unwrap_or(false);
let spec_obj_ok = value_at_path(payload, "application_specification")
.map(Value::is_object)
.unwrap_or(false);
if !app_file_ok && !claims_obj_ok && !spec_obj_ok {
return Err(String::from(
"Polish input requires at least one of 'application_file_id', 'original_claims', or 'application_specification'",
));
}
}
_ => return Err(format!("Unsupported workflow: {workflow}")),
}
Ok(())
}

fn detect_content_type(path: &Path) -> &'static str {
let ext = path
.extension()
Expand Down Expand Up @@ -789,6 +933,8 @@ fn execute_cli_task(opts: &CliOptions) -> Result<Value, String> {
json!({})
};
let input_json = build_cli_request_payload(opts, base_payload, base_url)?;
validate_cli_input_schema(workflow, &input_json)
.map_err(|e| format!("CLI input schema validation failed: {e}"))?;
let response = match workflow {
"draft" => {
let start_resp = post_json(base_url, "/api/v1/draft/start", &input_json, opts.timeout_sec, &llm)?;
Expand Down
40 changes: 40 additions & 0 deletions tests/test_traceability_resilience.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from __future__ import annotations

from agents.drafter_agents import (
_build_minimal_traceability_report,
_repair_traceability_min_fields,
)


def test_repair_traceability_fills_missing_claim_reports() -> None:
claims = {
"claims": [
{"claim_number": 1, "elements": ["特征A"]},
{"claim_number": 2, "elements": ["特征B"]},
]
}
half_structured = {
"reports": [
{
"claim_number": 1,
"elements_evidence": [],
"is_fully_supported": False,
}
],
"overall_risk_assessment": "too short",
}

repaired = _repair_traceability_min_fields(half_structured, claims)
assert len(repaired["reports"]) == 2
assert repaired["reports"][0]["elements_evidence"]
assert repaired["reports"][1]["elements_evidence"]
assert len(repaired["overall_risk_assessment"]) >= 20


def test_build_minimal_traceability_report_is_schema_valid() -> None:
claims = {"claims": [{"claim_number": 1, "elements": ["特征A"]}]}
fallback = _build_minimal_traceability_report(claims, "parse failed")
assert len(fallback["reports"]) >= 1
assert fallback["reports"][0]["claim_number"] == 1
assert fallback["reports"][0]["elements_evidence"][0]["support_level"] == "Unsupported"
assert len(fallback["overall_risk_assessment"]) >= 20
Loading