From 335da856b2ff7fa4f7235c1c21071c52011cb9d0 Mon Sep 17 00:00:00 2001 From: RichardDawald Date: Sat, 23 May 2026 19:33:04 +0800 Subject: [PATCH] Harden structured LLM outputs and add preflight CLI input schema validation --- agents/drafter_agents.py | 64 +++++++++++++++++++- agents/oa_agents.py | 31 +++++++++- frontend/src-tauri/src/main.rs | 104 +++++++++++++++++++++++++++++++++ 3 files changed, 195 insertions(+), 4 deletions(-) diff --git a/agents/drafter_agents.py b/agents/drafter_agents.py index 659be4a..f9c9b1d 100644 --- a/agents/drafter_agents.py +++ b/agents/drafter_agents.py @@ -42,6 +42,59 @@ def _duration_ms(started_at: float) -> int: return int((time.perf_counter() - started_at) * 1000) +def _build_minimal_traceability_report(state: DraftingState) -> dict[str, Any]: + claims_payload = state.get("claims") if isinstance(state.get("claims"), dict) else {} + claims = claims_payload.get("claims", []) if isinstance(claims_payload, dict) else [] + reports: list[dict[str, Any]] = [] + for claim in claims if isinstance(claims, list) else []: + if not isinstance(claim, dict): + continue + claim_no = int(claim.get("claim_number") or 0) + if claim_no <= 0: + continue + elements = claim.get("elements", []) + if not isinstance(elements, list) or len(elements) == 0: + fallback_text = str(claim.get("full_text", "")).strip() or "未提供可拆解的技术特征。" + elements = [fallback_text] + evidence = [] + for raw_feature in elements: + feature_text = str(raw_feature).strip() or "未提供特征文本。" + evidence.append( + { + "feature_text": feature_text, + "verbatim_quote": "模型输出异常,未能稳定提取原文支持证据,请人工复核。", + "support_level": "Unsupported", + "reasoning": "自动兜底:为避免流程中断,先标记为需人工核查。", + } + ) + reports.append( + { + "claim_number": claim_no, + "elements_evidence": evidence, + "is_fully_supported": False, + } + ) + if not reports: + reports = [ + { + "claim_number": 1, + "elements_evidence": [ + { + "feature_text": "未解析到权利要求特征。", + "verbatim_quote": "模型输出异常,未能稳定提取原文支持证据,请人工复核。", + "support_level": "Unsupported", + "reasoning": "自动兜底:为避免流程中断,先标记为需人工核查。", + } + ], + "is_fully_supported": False, + } + ] + return { + "reports": reports, + "overall_risk_assessment": "自动兜底报告:本次溯源输出不稳定,已按最小结构补全并全部标记为需人工复核。", + } + + def extract_tech_node( state: DraftingState, agent: BaseStructuredAgent[TechSummary], @@ -171,9 +224,16 @@ 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) + payload = result.model_dump() + # Node-level second pass: ensure minimum required shape even if upstream output drifts. + if not payload.get("reports"): + payload = _build_minimal_traceability_report(state) + except Exception: + payload = _build_minimal_traceability_report(state) return { - "claim_traceability": result.model_dump(), + "claim_traceability": payload, "current_step": "traceability_check_node", "status": "running", "node_latency_ms": _duration_ms(started_at), diff --git a/agents/oa_agents.py b/agents/oa_agents.py index a9af529..4e1886b 100644 --- a/agents/oa_agents.py +++ b/agents/oa_agents.py @@ -68,6 +68,27 @@ def _duration_ms(started_at: float) -> int: return int((time.perf_counter() - started_at) * 1000) +def _build_minimal_response_traceability_report() -> dict[str, Any]: + fallback_finding = { + "severity": "WARNING", + "risk_category": "LOGIC_INCONSISTENCY", + "problematic_text": "无", + "audit_reasoning": "模型输出异常,已触发节点级结构兜底,请人工复核最终答复文本。", + "suggested_remedy": "建议人工核查权利要求支持依据、论证一致性及不利自认风险。", + } + return { + "global_go_no_go": "NO_GO", + "support_basis_audit": [fallback_finding], + "logic_consistency_audit": [fallback_finding], + "harmful_admission_audit": [], + "final_strategy_summary": "自动兜底:响应溯源报告输出不稳定,已按最小结构补全并置为NO_GO待人工复核。", + "claim_support_ok": False, + "logic_consistency_ok": False, + "findings": [fallback_finding], + "final_risk_summary": "自动兜底:请人工复核后再提交。", + } + + def _safe_int(value: Any, default: int = 0) -> int: try: return int(value) @@ -891,8 +912,14 @@ def response_traceability_node( f"[ARGUMENT_DRAFT (The Legal Response to Audit)]\n{state.get('argument_draft')}\n\n" f"[STRATEGY_DECISION & STRESS TEST (For Context)]\n{state.get('strategy_decision')}\n{state.get('stress_test_report')}" ) - report = agent.run_structured(prompt=prompt, output_model=ResponseTraceabilityReport) - payload = report.model_dump() + try: + report = agent.run_structured(prompt=prompt, output_model=ResponseTraceabilityReport) + payload = report.model_dump() + # Node-level second pass to avoid whole-chain failure on semi-structured outputs. + if not isinstance(payload.get("support_basis_audit"), list): + payload = _build_minimal_response_traceability_report() + except Exception: + payload = _build_minimal_response_traceability_report() if not payload.get("support_basis_audit") and payload.get("findings"): payload["support_basis_audit"] = payload.get("findings", []) if "logic_consistency_audit" not in payload: diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index 7fa884c..88db015 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -452,6 +452,109 @@ fn read_input_json(input_arg: &str) -> Result { } } +fn expect_object_field(root: &Value, key: &str, errors: &mut Vec) { + if let Some(v) = root.get(key) { + if !v.is_object() { + errors.push(format!("`{key}` must be an object")); + } + } +} + +fn expect_string_or_null_field(root: &Value, key: &str, errors: &mut Vec) { + if let Some(v) = root.get(key) { + if !(v.is_string() || v.is_null()) { + errors.push(format!("`{key}` must be a string or null")); + } + } +} + +fn expect_string_array_field(root: &Value, key: &str, errors: &mut Vec) { + if let Some(v) = root.get(key) { + match v.as_array() { + Some(arr) => { + for (idx, item) in arr.iter().enumerate() { + if !item.is_string() { + errors.push(format!("`{key}[{idx}]` must be a string")); + } + } + } + None => errors.push(format!("`{key}` must be an array of strings")), + } + } +} + +fn validate_cli_input_schema(workflow: &str, payload: &Value) -> Result<(), String> { + let obj = payload + .as_object() + .ok_or_else(|| String::from("`--input` JSON root must be an object"))?; + let root = Value::Object(obj.clone()); + let mut errors: Vec = Vec::new(); + + if let Some(v) = root.get("idempotency_key") { + if !v.is_string() { + errors.push(String::from("`idempotency_key` must be a string")); + } + } + expect_object_field(&root, "metadata", &mut errors); + + match workflow { + "draft" => { + expect_string_or_null_field(&root, "disclosure_text", &mut errors); + if let Some(v) = root.get("disclosure_file_id") { + if !(v.is_string() || v.is_null()) { + errors.push(String::from("`disclosure_file_id` must be a string or null")); + } + } + } + "oa" => { + expect_string_or_null_field(&root, "oa_text", &mut errors); + expect_object_field(&root, "original_claims", &mut errors); + expect_object_field(&root, "application_specification", &mut errors); + expect_string_array_field(&root, "prior_arts_paths", &mut errors); + expect_string_array_field(&root, "prior_art_file_ids", &mut errors); + for key in ["oa_notice_file_id", "application_file_id"] { + if let Some(v) = root.get(key) { + if !(v.is_string() || v.is_null()) { + errors.push(format!("`{key}` must be a string or null")); + } + } + } + } + "compare" => { + expect_object_field(&root, "original_claims", &mut errors); + expect_object_field(&root, "application_specification", &mut errors); + expect_string_array_field(&root, "prior_arts_paths", &mut errors); + expect_string_array_field(&root, "prior_art_file_ids", &mut errors); + for key in ["application_file_id", "comparison_goal"] { + if let Some(v) = root.get(key) { + if !(v.is_string() || v.is_null()) { + errors.push(format!("`{key}` must be a string or null")); + } + } + } + } + "polish" => { + expect_object_field(&root, "original_claims", &mut errors); + expect_object_field(&root, "application_specification", &mut errors); + if let Some(v) = root.get("application_file_id") { + if !(v.is_string() || v.is_null()) { + errors.push(String::from("`application_file_id` must be a string or null")); + } + } + } + _ => errors.push(format!("Unsupported workflow: {workflow}")), + } + + if errors.is_empty() { + Ok(()) + } else { + Err(format!( + "CLI input schema validation failed for workflow `{workflow}`: {}", + errors.join("; ") + )) + } +} + fn detect_content_type(path: &Path) -> &'static str { let ext = path .extension() @@ -788,6 +891,7 @@ fn execute_cli_task(opts: &CliOptions) -> Result { } else { json!({}) }; + validate_cli_input_schema(workflow, &base_payload)?; let input_json = build_cli_request_payload(opts, base_payload, base_url)?; let response = match workflow { "draft" => {