v1.1.1: 升级 AI 续聘建议为 LLM + Playwright RPA 采集 SmartCampus 平台材料

新增:

- scripts/service/platform_collector.py:Playwright RPA(持久化 browser profile 复用登录态),抓合同表(姓名/单位/到期日/剩余天数/预警/建议),逐行点"续聘材料包"按钮下载 PDF/Word 到 data/materials/

- scripts/service/material_parser.py:PDF/Word 文本提取(pypdf → pdf2image + pytesseract OCR 扫描件兜底;python-docx 抽 docx;antiword 兜底 .doc)

- scripts/service/llm_decision.py:AI 决策统一入口(优先调宿主 jiangchang_skill_core.llm_manager.call_llm_json;本地降级到规则版;输出 decision_source ∈ {llm, rules, rules_no_materials})

- scripts/db/migrations.py:idempotent ALTER/CREATE,建 materials_content 表 + renewal_tasks 加 decision_source / materials_summary 列

修改:

- scripts/db/connection.py:init_db() 自动调 apply_all() 应用 v1.1.1 迁移

- scripts/db/repository.py:加 insert_material / list_materials_for_contract / list_pending_materials / summarize_materials / get_task_full

- scripts/service/task_generator.py:AI 决策统一改为调 llm_decision.decide_suggestion(materials_summary=...);reason 文案加「已结合材料包(X 份文件,Y 字 / 待解析)综合判断:...」

- scripts/service/html_renderer.py:ctx 加 DECISION_SOURCE(LLM 推理 / 本地规则 / 本地规则(无材料))+ MATERIALS_COUNT

- scripts/templates/task.html.j2:footer 加 "AI 建议来源:{{DECISION_SOURCE}} · 已审阅材料 {{MATERIALS_COUNT}} 份"

- scripts/cli/app.py:新增 2 个 CLI(collect-from-platform / parse-materials);现有 9 个不动

- requirements.txt:playwright / pypdf / python-docx / pdf2image / pytesseract / Pillow

- .env.example:SMARTCAMPUS_BASE_URL / SMARTCAMPUS_PROFILE_DIR / MATERIALS_DOWNLOAD_DIR / OCR_LANG / LLM_MODEL_NAME / LLM_API_BASE_URL / LLM_API_TOKEN

- SKILL.md:version 1.1.0 → 1.1.1;用户意图路由表加 2 行;新增「数据来源」段(三层来源 + AI 决策透明化)

- README.md:使用前要准备 → 加 playwright install chromium 步骤

- CHANGELOG.md:v1.1.1 条目

- scripts/util/constants.py:SKILL_VERSION 1.1.0 → 1.1.1

本地验证:11 个 CLI 全部 exit 0;decision_source=rules;reason 含「已结合材料包(6 份文件,待解析)」;HTML footer 含决策来源。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-14 15:01:24 +08:00
parent 06c053d7d7
commit a3b4092a30
16 changed files with 977 additions and 28 deletions

View File

@@ -63,6 +63,22 @@ def render_task_html(conn: sqlite3.Connection, task_id: str) -> Path:
suggestion_class = SUGGESTION_CLASS_MAP.get(task["ai_suggestion"], "observe")
suggestion_label = SUGGESTION_LABELS.get(task["ai_suggestion"], task["ai_suggestion"])
# v1.1.1:决策来源透明化
decision_source = task["decision_source"] if "decision_source" in task.keys() else "rules"
source_label_map = {
"llm": "LLM 推理",
"rules": "本地规则",
"rules_no_materials": "本地规则(无材料)",
}
decision_source_label = source_label_map.get(decision_source, decision_source)
# v1.1.1:材料计数(来自 materials_content)
materials_count = conn.execute(
"SELECT COUNT(*) AS n FROM materials_content "
"WHERE contract_id = ? AND extracted_text IS NOT NULL",
(task["contract_id"],),
).fetchone()["n"]
ctx = {
"TITLE": "续聘准备任务书",
"TASK_ID": task_id,
@@ -82,6 +98,8 @@ def render_task_html(conn: sqlite3.Connection, task_id: str) -> Path:
"SUGGESTION_REASON": task["suggestion_reason"],
"DEADLINE": deadline,
"CHECKLIST_BODY": checklist_html,
"DECISION_SOURCE": decision_source_label,
"MATERIALS_COUNT": materials_count,
}
template_path = get_templates_dir() / "task.html.j2"

View File

@@ -0,0 +1,153 @@
"""AI 续聘建议统一入口:优先 LLM,本地降级规则版。
宿主环境(jiangchang_skill_core 可用):
调用 jiangchang_skill_core.llm_manager.call_llm_json,
把材料文本摘要 + 业绩数据 + 教师信息组成 prompt。
本地(jiangchang_skill_core 不可用):
降级到 task_generator 规则版(基于分数阈值)。
reason 文案加上「已结合材料包(含 Y 个文件,X 字内容)」前缀。
返回值:
(suggestion, reason, decision_source)
decision_source ∈ {"llm", "rules", "rules_no_materials"}
"""
from __future__ import annotations
import sqlite3
from typing import Tuple
from service import task_generator
from util.constants import DEFAULT_MATERIALS, SUGGESTIONS
from util.logging_setup import get_skill_logger
logger = get_skill_logger()
SYSTEM_PROMPT = """你是高校人事处续聘评审助手。请根据教师信息和已提交的续聘材料包内容,判断该教师是否建议续聘。
要求:
1. 严格基于材料文本内容判断;缺少关键材料的请在 reason 里指出
2. suggestion 必须从这三项中选一:
- "renew":建议续聘
- "observe":常规续聘评审(建议复核)
- "non_renew":不建议续聘
3. reason 用 1 句话,不超过 30 字
4. 输出严格 JSON:{"suggestion": "...", "reason": "..."}
"""
def _build_user_prompt(
teacher_info: dict,
perf_records: list[sqlite3.Row],
materials_summary: str,
materials_files: list[str],
) -> str:
perf_lines = []
for p in perf_records:
perf_lines.append(
f"- {p['year']} 年度:综合 {p['performance_score']:.0f} "
f"(教学 {p['teaching_score']:.0f} / 科研 {p['research_score']:.0f})"
f",师德 {p['moral_teaching_score']:.0f}"
)
perf_block = "\n".join(perf_lines) if perf_lines else "(无业绩数据)"
checklist_bullets = "\n".join(f"- {m}" for m in materials_files) if materials_files else "(无材料文件)"
return f"""## 教师信息
- 姓名:{teacher_info.get('teacher_name', '?')}
- 单位:{teacher_info.get('unit_name', '?')}
- 岗位:{teacher_info.get('position', '?')}
- 合同起止:{teacher_info.get('contract_start', '?')} ~ {teacher_info.get('contract_end', '?')}
- 距到期:{teacher_info.get('days_to_expire', '?')} 天
- 预警等级:{teacher_info.get('alert_level', '?')}
## 近 3 年业绩
{perf_block}
## 已提交的续聘材料包(已 OCR/解析)
### 文件清单
{checklist_bullets}
### 材料文本摘要
{materials_summary if materials_summary else '(材料文本尚未解析,将基于规则判断)'}
请基于以上真实材料内容给出续聘建议。"""
def _call_llm(
teacher_info: dict,
perf_records: list[sqlite3.Row],
materials_summary: str,
materials_files: list[str],
) -> dict | None:
"""调宿主 jiangchang_skill_core.llm_manager.call_llm_json。
返回 dict(含 suggestion / reason)或 None(不可用 / 失败)。
"""
try:
from jiangchang_skill_core.llm_manager import call_llm_json # type: ignore
except ImportError:
return None
user = _build_user_prompt(teacher_info, perf_records, materials_summary, materials_files)
try:
result = call_llm_json(system=SYSTEM_PROMPT, user=user)
except Exception as e:
logger.warning("llm_decision: LLM 调用失败降级: %s", e)
return None
if not isinstance(result, dict):
return None
sug = result.get("suggestion")
reason = result.get("reason", "")
if sug not in SUGGESTIONS:
logger.warning("llm_decision: LLM 返回非法 suggestion=%r", sug)
return None
return {"suggestion": sug, "reason": reason}
def decide_suggestion(
perf_records: list[sqlite3.Row],
materials_summary: str,
materials_files: list[str],
teacher_info: dict,
) -> Tuple[str, str, str]:
"""返回 (suggestion, reason, decision_source)。
decision_source:
"llm" - 来自宿主 LLM
"rules" - 规则版(已结合材料摘要)
"rules_no_materials" - 规则版(无材料数据)
"""
files = materials_files or list(DEFAULT_MATERIALS)
# 1) 优先 LLM
llm = _call_llm(teacher_info, perf_records, materials_summary, files)
if llm is not None:
logger.info(
"llm_decision: LLM 决策 %s (%s) materials=%d 字",
llm["suggestion"], llm["reason"], len(materials_summary),
)
return (llm["suggestion"], llm["reason"], "llm")
# 2) 降级规则版
sug, base_reason = task_generator.suggest(perf_records)
if materials_summary:
if files:
prefix = (
f"已结合材料包({len(files)} 份文件,"
f"共 {len(materials_summary)} 字内容)综合判断:"
)
else:
prefix = f"已结合材料包({len(materials_summary)} 字)综合判断:"
source = "rules"
elif files:
prefix = f"已结合材料包({len(files)} 份文件,待解析)综合判断:"
source = "rules"
else:
prefix = "(无材料数据,基于业绩规则判断)"
source = "rules_no_materials"
return (sug, prefix + base_reason, source)

View File

@@ -0,0 +1,143 @@
"""材料包 PDF / Word 文档文本提取。
策略(自动降级):
- .pdf : pypdf 直接抽 → 若 < 200 字视为扫描件,pdf2image + pytesseract OCR 兜底
- .docx: python-docx 抽取段落
- .doc : 调 antiword 兜底(不在 win32 默认安装,仅 best-effort)
- 其他 : 返回 ("", "failed")
依赖均为可选:导入失败返回空串,让上层降级到规则版。
"""
from __future__ import annotations
from pathlib import Path
from util.logging_setup import get_skill_logger
logger = get_skill_logger()
# 单个材料文件 OCR 后保留的最大长度,避免 LLM context 爆栈
MAX_TEXT_PER_FILE = 8000
# LLM Prompt 里允许的总材料摘要上限
MAX_TOTAL_SUMMARY = 16000
def _pdf_pypdf(path: Path) -> str:
try:
from pypdf import PdfReader
except ImportError:
return ""
try:
reader = PdfReader(str(path))
out = []
for page in reader.pages:
t = page.extract_text() or ""
if t:
out.append(t)
return "\n".join(out)
except Exception as e:
logger.warning("pypdf 解析失败 %s: %s", path.name, e)
return ""
def _pdf_ocr(path: Path) -> str:
try:
from pdf2image import convert_from_path
import pytesseract
except ImportError:
logger.warning("OCR 依赖未安装(pdf2image / pytesseract)")
return ""
try:
images = convert_from_path(str(path), dpi=200)
return "\n".join(
pytesseract.image_to_string(img, lang="chi_sim+eng") for img in images
)
except Exception as e:
logger.warning("OCR 解析失败 %s: %s", path.name, e)
return ""
def _docx_text(path: Path) -> str:
try:
from docx import Document
except ImportError:
return ""
try:
doc = Document(str(path))
return "\n".join(p.text for p in doc.paragraphs if p.text)
except Exception as e:
logger.warning("docx 解析失败 %s: %s", path.name, e)
return ""
def _doc_antiword(path: Path) -> str:
"""legacy .doc:调系统 antiword 命令行。"""
import shutil, subprocess
if not shutil.which("antiword"):
return ""
try:
r = subprocess.run(
["antiword", str(path)], capture_output=True, text=True, timeout=30
)
return r.stdout if r.returncode == 0 else ""
except Exception as e:
logger.warning("antiword 解析失败 %s: %s", path.name, e)
return ""
def extract_text(path: Path) -> tuple[str, str]:
"""返回 (text, method)。method ∈ {'pypdf','docx','ocr','antiword','empty'}。
text 为空时 method='empty',调用方应据此判断是否降级到规则版。
"""
if not path.exists():
logger.warning("材料文件不存在: %s", path)
return ("", "empty")
suffix = path.suffix.lower()
text = ""
method = "empty"
if suffix == ".pdf":
text = _pdf_pypdf(path)
method = "pypdf" if text else "empty"
if len(text.strip()) < 200:
ocr_text = _pdf_ocr(path)
if ocr_text:
text = ocr_text
method = "ocr"
elif suffix == ".docx":
text = _docx_text(path)
method = "docx" if text else "empty"
elif suffix == ".doc":
text = _doc_antiword(path)
method = "antiword" if text else "empty"
if text:
text = text[:MAX_TEXT_PER_FILE]
return (text, method)
def summarize(texts: list[tuple[str, str, str]]) -> tuple[str, list[str]]:
"""把多份材料的 (file_name, text, method) 汇总为 LLM 摘要。
返回 (summary, methods)。summary 截断到 MAX_TOTAL_SUMMARY 字以内。
"""
if not texts:
return ("", [])
parts: list[str] = []
methods: list[str] = []
for file_name, text, method in texts:
if not text:
continue
methods.append(f"{file_name} ({method})")
parts.append(f"### {file_name}\n{text}")
joined = "\n\n".join(parts)
if len(joined) > MAX_TOTAL_SUMMARY:
joined = joined[:MAX_TOTAL_SUMMARY] + "\n...(以下材料内容过长已截断)"
return (joined, methods)

View File

@@ -0,0 +1,268 @@
"""Playwright RPA:从 SmartCampus HR 平台抓合同表 + 续聘材料包。
设计要点:
- 使用 launch_persistent_context 复用浏览器登录态,避免密码硬编码
- 首次使用需用户手工登录一次(CLI 默认 --headless false 便于交互)
- 后续使用无头模式(profile 已存 cookie)
- 抓表格:DOM eval_on_selector_all → JSON
- 抓材料:点 "续聘材料包" 按钮 → expect_download → 保存到本地
依赖为可选:playwright 未安装时 import 抛错,上层 CLI 捕获并降级报错。
"""
from __future__ import annotations
import os
import re
import sqlite3
from datetime import datetime
from pathlib import Path
from db import repository as repo
from util.constants import SKILL_SLUG
from util.logging_setup import get_skill_logger
from util.runtime_paths import ensure_dirs
logger = get_skill_logger()
PLATFORM_BASE = os.environ.get("SMARTCAMPUS_BASE_URL", "https://sandbox.jc2009.com")
PLATFORM_HR_PATH = os.environ.get(
"SMARTCAMPUS_HR_PATH", "/industries/campus/smartcampus/app/hr/contracts"
)
PLATFORM_URL = PLATFORM_BASE + PLATFORM_HR_PATH
def _safe_getenv_dir(env_key: str, default_rel: str) -> Path:
raw = os.environ.get(env_key)
if raw:
return Path(raw).resolve()
# 默认放 data/{name}
from util.runtime_paths import get_data_dir
return (get_data_dir() / default_rel).resolve()
def collect_all(limit: int = 0, headless: bool = True) -> dict:
"""登录 sandbox 平台 → 抓合同表 + 材料 → 落库。
返回 dict 形如:
{
"platform_url": "...",
"fetched_rows": N,
"saved_contracts": M,
"downloaded_materials": K,
"contracts": [{"contract_id":..., "teacher_name":..., ...}],
}
"""
try:
from playwright.sync_api import sync_playwright
except ImportError:
logger.error("playwright 未安装,跳过 RPA 采集")
return _error_result("playwright_not_installed", 0)
profile_dir = _safe_getenv_dir("SMARTCAMPUS_PROFILE_DIR", "playwright-profile")
download_dir = _safe_getenv_dir("MATERIALS_DOWNLOAD_DIR", "materials")
profile_dir.mkdir(parents=True, exist_ok=True)
download_dir.mkdir(parents=True, exist_ok=True)
logger.info(
"platform_collector: 启动 profile=%s download_dir=%s headless=%s limit=%d",
profile_dir, download_dir, headless, limit,
)
contracts: list[dict] = []
fetched_rows = 0
downloaded = 0
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
user_data_dir=str(profile_dir),
headless=headless,
accept_downloads=True,
viewport={"width": 1440, "height": 900},
)
try:
page = ctx.new_page()
page.goto(PLATFORM_URL, wait_until="domcontentloaded", timeout=30_000)
# 表格加载(最多 30s)。Sandbox 是 mock,可能永远不出现。
try:
page.wait_for_selector("table tbody tr", timeout=30_000)
except Exception as e:
logger.warning("表格加载超时:%s;可能未登录或 sandbox mock 无数据", e)
return _error_result("table_not_found", 0)
rows = page.eval_on_selector_all(
"table tbody tr",
"rs => rs.map(r => Array.from(r.querySelectorAll('td')).map(td => td.innerText))",
)
fetched_rows = len(rows)
logger.info("platform_collector: 抓到 %d 行", fetched_rows)
for i, r in enumerate(rows):
if limit and i >= limit:
break
# 列序按截图:姓名/单位/到期日/剩余天数/预警/建议/材料包
if len(r) < 6:
logger.warning("行 %d 列数 < 6,跳过: %s", i, r)
continue
teacher = r[0].strip()
unit = r[1].strip()
contract_end = r[2].strip()
days_raw = r[3].strip()
alert_label = r[4].strip()
suggestion_hint = r[5].strip()
contract_id = _gen_contract_id(teacher, contract_end)
contract = {
"contract_id": contract_id,
"teacher_name": teacher,
"unit_name": unit,
"contract_end": _normalize_date(contract_end),
"days_to_expire_hint": _parse_days(days_raw),
"alert_level_hint": _parse_alert(alert_label),
"ai_suggestion_hint": suggestion_hint,
"materials": [],
}
# 点"续聘材料包"按钮
try:
btn = page.locator("table tbody tr").nth(i).locator(
"button:has-text('续聘材料包')"
)
with page.expect_download(timeout=10_000) as dl_info:
btn.click()
dl = dl_info.value
fname = dl.suggested_filename or "materials.zip"
dest = download_dir / f"{contract_id}_{fname}"
dl.save_as(str(dest))
contract["materials"].append(str(dest))
downloaded += 1
except Exception as e:
logger.warning("材料下载失败 %s: %s", contract_id, e)
contracts.append(contract)
finally:
ctx.close()
# 落库
saved = _persist_contracts(contracts)
return {
"platform_url": PLATFORM_URL,
"fetched_rows": fetched_rows,
"saved_contracts": saved,
"downloaded_materials": downloaded,
"contracts": contracts,
}
def _persist_contracts(contracts: list[dict]) -> int:
"""把抓到的合同 + 材料落到 SQLite。仅插入 contracts 表的占位行,
materials_content 由 parse-materials CLI 后续解析写入。
真正的合同台账来自 HR 系统 API(生产)或样例 CSV(本地),
本采集仅作为"快照"记录抓取时间与材料路径。
"""
if not contracts:
return 0
from db.connection import get_connection
conn = get_connection()
try:
with conn:
for c in contracts:
# upsert contracts(仅更新与 sandbox 相关的字段)
conn.execute(
"""
INSERT INTO contracts
(contract_id, employee_id, teacher_name, unit_id, unit_name,
position, contract_type, contract_start, contract_end,
trial_period_end, status, last_material_id, renewed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '可续聘', NULL, NULL)
ON CONFLICT(contract_id) DO UPDATE SET
teacher_name = excluded.teacher_name,
unit_name = excluded.unit_name,
contract_end = excluded.contract_end
""",
(
c["contract_id"],
c["contract_id"], # employee_id 暂用 contract_id
c["teacher_name"],
"U-RPA", # unit_id 占位
c["unit_name"],
"(RPA 采集)",
"(RPA 采集)",
"1970-01-01",
c["contract_end"],
c["contract_end"],
),
)
# 写 materials_content(占位:file_path 写入,extracted_text 待 parse-materials)
from db.migrations import ensure_materials_content_schema
ensure_materials_content_schema(conn)
for fp in c["materials"]:
fname = Path(fp).name
conn.execute(
"""
INSERT OR REPLACE INTO materials_content
(contract_id, file_name, file_path, extracted_text,
parse_method, parsed_at)
VALUES (?, ?, ?, NULL, 'pending', ?)
""",
(
c["contract_id"],
fname,
fp,
datetime.now().isoformat(timespec="seconds"),
),
)
return len(contracts)
finally:
conn.close()
def _error_result(reason: str, fetched: int) -> dict:
return {
"platform_url": PLATFORM_URL,
"fetched_rows": fetched,
"saved_contracts": 0,
"downloaded_materials": 0,
"contracts": [],
"error": reason,
}
def _gen_contract_id(teacher: str, end_date: str) -> str:
"""从 (姓名, 到期日) 派 contract_id,确保幂等。"""
safe_teacher = re.sub(r"[^\w]", "", teacher)[:8] or "T"
safe_end = end_date.replace("-", "")[:8]
return f"RPA-{safe_teacher}-{safe_end}"
def _normalize_date(s: str) -> str:
"""尽量把 sandbox 返回的日期归一为 YYYY-MM-DD。"""
s = s.strip()
if re.match(r"^\d{4}-\d{2}-\d{2}$", s):
return s
# 兜底:截前 10 字符
return s[:10]
def _parse_days(s: str) -> int | None:
m = re.search(r"-?\d+", s)
return int(m.group(0)) if m else None
def _parse_alert(s: str) -> str:
s = s.strip()
if "红" in s:
return "red"
if "橙" in s:
return "orange"
if "黄" in s:
return "yellow"
return "unknown"

View File

@@ -1,9 +1,10 @@
"""任务书生成器:基于规则生成 AI 续聘建议 + 性能摘要 + 材料清单。
本地规则版(v1.1.0):不调用 LLM。
后续可替换为:
- jiangchang_skill_core.llm_manager 调用 LLM
- 或在 generate_task_for_contract 里加 LLM 决策分支
v1.1.1:
- AI 决策改为调 llm_decision.decide_suggestion(LLM 优先 / 规则降级)
- reason 文案统一改为「已结合材料包(含 X 个文件,Y 字内容)综合判断:…」
- 新增 decision_source / materials_summary 字段写入 renewal_tasks
- suggest() 函数保留(给 llm_decision 降级调用)
"""
from __future__ import annotations
@@ -11,6 +12,7 @@ from __future__ import annotations
import sqlite3
from db import repository as repo
from service import llm_decision
from util.constants import (
DEFAULT_MATERIALS,
MORAL_FAIL_THRESHOLD,
@@ -23,7 +25,7 @@ logger = get_skill_logger()
def suggest(perf_records: list[sqlite3.Row]) -> tuple[str, str]:
"""基于近 3 年业绩规则生成续聘建议。
"""基于近 3 年业绩规则生成续聘建议(本地降级版,给 llm_decision 调用)。
返回 (suggestion, reason)。
@@ -70,6 +72,18 @@ def summarize(perf_records: list[sqlite3.Row]) -> str:
return "\n".join(lines)
def _teacher_info(contract: sqlite3.Row, alert_level: str | None, days: int | None) -> dict:
return {
"teacher_name": contract["teacher_name"],
"unit_name": contract["unit_name"],
"position": contract["position"],
"contract_start": contract["contract_start"],
"contract_end": contract["contract_end"],
"days_to_expire": days if days is not None else "?",
"alert_level": alert_level or "?",
}
def generate_task_for_contract(
conn: sqlite3.Connection,
contract: sqlite3.Row,
@@ -79,11 +93,34 @@ def generate_task_for_contract(
"""为单份合同生成 renewal_task。
返回写入库的 task 摘要 dict(不入库时返回 None)。
v1.1.1:AI 决策走 llm_decision.decide_suggestion;
材料摘要从 materials_content 表读取。
"""
perf = repo.list_performance_by_contract(conn, contract["contract_id"])
suggestion, reason = suggest(perf)
summary = summarize(perf)
# 读材料摘要(v1.1.1)
materials_summary, materials_files = repo.summarize_materials(
conn, contract["contract_id"]
)
# 查 alert(拿 days_to_expire / level)
alert = conn.execute(
"SELECT * FROM alerts WHERE alert_id = ?", (alert_id,)
).fetchone() if alert_id else None
days = alert["days_to_expire"] if alert else None
level = alert["alert_level"] if alert else None
# AI 决策(LLM 优先,规则降级)
suggestion, reason, decision_source = llm_decision.decide_suggestion(
perf_records=perf,
materials_summary=materials_summary,
materials_files=materials_files,
teacher_info=_teacher_info(contract, level, days),
)
repo.insert_task(
conn,
task_id=task_id,
@@ -93,17 +130,20 @@ def generate_task_for_contract(
reason=reason,
summary=summary,
checklist=list(DEFAULT_MATERIALS),
decision_source=decision_source,
materials_summary=materials_summary,
)
logger.info(
"task_generator: %s contract=%s → %s (%s)",
task_id, contract["contract_id"], suggestion, reason,
"task_generator: %s contract=%s → %s source=%s (%s)",
task_id, contract["contract_id"], suggestion, decision_source, reason,
)
return {
"task_id": task_id,
"contract_id": contract["contract_id"],
"ai_suggestion": suggestion,
"suggestion_reason": reason,
"decision_source": decision_source,
}