"""AI 整理:把粘贴的周报原文整理成结构化执行记录。 - 主路径:调用 OpenAI 兼容接口(DeepSeek / 通义 / Moonshot / OpenAI / Ollama ...) - 兜底:本地规则解析,无需任何 Key 也能用 """ from __future__ import annotations import json import re from typing import Any import httpx from . import config from .models import Setting SYSTEM_PROMPT = """你是一位研发部门管理助理,负责把项目周报/月报原文整理成结构化的执行记录。 请严格输出 JSON(不要 Markdown 代码块、不要解释),结构如下: { "summary": "一句话摘要,30字以内", "progress": 60, "status": "in_progress", "risks": ["风险或问题1", "风险或问题2"], "next_steps": ["下一步计划1"], "tasks": [ {"name": "子任务名称", "content": "该子任务的执行情况", "progress": 80, "status": "in_progress", "risk": ""} ] } 规则: 1. status 只能是 not_started / in_progress / done / at_risk / blocked / paused 之一。 2. progress 是 0-100 的整数;原文有百分比就用原文的,没有就根据语义估算。 3. tasks 尽量拆分原文中的多条独立事项,每条 name 不超过 20 字;原文没有明显分条时,tasks 允许只有 1 条。 4. risks 只放风险、阻塞、待协调事项;没有就给空数组。 5. 不要编造原文没有的事实。 """ USER_TEMPLATE = """项目:{project} 周期:{period} ---- 原始内容 ---- {raw} ---- 结束 ---- """ # -------------------------------------------------------------------------- # 配置读取 # -------------------------------------------------------------------------- def get_ai_settings(db) -> dict[str, str]: values = dict(config.DEFAULT_AI_SETTINGS) for row in db.query(Setting).all(): values[row.key] = row.value or "" return values def ai_enabled(db) -> bool: s = get_ai_settings(db) return bool((s.get("ai_api_key") or "").strip() and (s.get("ai_api_base") or "").strip()) # -------------------------------------------------------------------------- # 规则兜底解析 # -------------------------------------------------------------------------- _CN_NUM = {"一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9, "十": 10} _SPLIT_RE = re.compile(r"(?:^|\n)\s*(?:\(?\d{1,2}[))、..::]|[一二三四五六七八九十]{1,2}[、..]|[-•*·]\s)") _PCT_RE = re.compile(r"(\d{1,3}(?:\.\d+)?)\s*%") _STATUS_RULES = [ (("阻塞", "卡住", "卡点", "停滞", "无法推进", "推不动"), "blocked"), (("暂停", "终止", "搁置"), "paused"), (("延期", "延迟", "滞后", "赶不上", "超期"), "at_risk"), (("风险", "隐患", "待协调"), "at_risk"), (("未开始", "待启动", "未启动"), "not_started"), (("完成", "已交付", "已验收", "已上线", "已发布"), "done"), ] def _guess_status(text: str) -> str: """先判负面信号,再用百分比判定,最后才用『完成』类关键词。""" low = (text or "").lower() pct = _guess_progress(text) for words, code in _STATUS_RULES: if code in ("done", "not_started"): continue if any(w in low for w in words): return code if pct is not None: return "done" if pct >= 100 else "in_progress" for words, code in _STATUS_RULES: if code in ("done", "not_started") and any(w in low for w in words): return code return "in_progress" def _guess_progress(text: str) -> int | None: from .constants import extract_percent return extract_percent(text) def _split_items(raw: str) -> list[str]: text = (raw or "").strip() if not text: return [] parts = _SPLIT_RE.split("\n" + text) parts = [p.strip() for p in parts if p and p.strip()] if len(parts) <= 1: parts = [ln.strip() for ln in text.splitlines() if ln.strip()] return parts or [text] def _is_header(s: str) -> bool: """形如『本周 NEX云桌面 进展:』的标题行,不应成为子任务。""" s = s.strip() return len(s) <= 40 and bool(re.search(r"[::]\s*$", s)) def rule_parse(raw: str, project_name: str = "") -> dict[str, Any]: items = _split_items(raw) if len(items) > 1: items = [i for i in items if not _is_header(i)] or items tasks: list[dict[str, Any]] = [] for it in items[:12]: flat = it.replace("\n", " ").strip() tasks.append( { "name": flat[:20] + ("…" if len(flat) > 20 else ""), "content": it, "progress": _guess_progress(it), "status": _guess_status(it), "risk": "", } ) flat_all = (raw or "").replace("\n", " ") overall_status = _guess_status(flat_all) if tasks: codes = [t["status"] for t in tasks] for cand in ("blocked", "paused", "at_risk", "not_started", "in_progress", "done"): if cand in codes: overall_status = cand break progresses = [t["progress"] for t in tasks if t["progress"] is not None] progress = int(round(sum(progresses) / len(progresses))) if progresses else None risks, next_steps = [], [] for ln in (raw or "").splitlines(): ln = ln.strip() if not ln: continue if any(w in ln for w in ("风险", "阻塞", "问题", "待协调", "无法", "延期", "延迟")): risks.append(ln[:120]) if any(w in ln for w in ("下一步", "下周", "计划", "后续", "待办")): next_steps.append(ln[:120]) summary = "" if items: summary = items[0].replace("\n", " ").strip()[:60] return { "summary": summary, "progress": progress, "status": overall_status, "risks": risks[:5], "next_steps": next_steps[:5], "tasks": tasks, } # -------------------------------------------------------------------------- # LLM 解析 # -------------------------------------------------------------------------- def _extract_json(text: str) -> dict[str, Any]: text = (text or "").strip() if text.startswith("```"): text = re.sub(r"^```(?:json)?", "", text).strip() text = re.sub(r"```$", "", text).strip() start, end = text.find("{"), text.rfind("}") if start >= 0 and end > start: text = text[start : end + 1] return json.loads(text) def llm_parse(raw: str, project_name: str, period_label: str, db) -> dict[str, Any]: s = get_ai_settings(db) base = (s.get("ai_api_base") or "").rstrip("/") payload = { "model": s.get("ai_model") or "deepseek-chat", "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, { "role": "user", "content": USER_TEMPLATE.format( project=project_name or "(未指定)", period=period_label or "(未指定)", raw=raw[:12000], ), }, ], "temperature": float(s.get("ai_temperature") or 0.2), "stream": False, } headers = { "Authorization": f"Bearer {s.get('ai_api_key') or ''}", "Content-Type": "application/json", } with httpx.Client(timeout=config.AI_TIMEOUT) as client: resp = client.post(f"{base}/chat/completions", json=payload, headers=headers) resp.raise_for_status() data = resp.json() content = data["choices"][0]["message"]["content"] parsed = _extract_json(content) parsed.setdefault("tasks", []) parsed.setdefault("risks", []) parsed.setdefault("next_steps", []) parsed["model"] = s.get("ai_model") return parsed # ========================================================================== # 定制交付项目:阶段 / 金额 / 计收 # ========================================================================== CUSTOM_SYSTEM_PROMPT = """你是定制交付项目的管理助理,负责把项目周报原文整理成结构化记录。 定制项目关注的是商务阶段流转(商机→签单→开发→交付→验收→计收)与收入确认。 请严格输出 JSON(不要 Markdown 代码块、不要解释),结构如下: { "stage": "developing", "progress": 60, "amount": 150000, "revenue_amount": 0, "revenue_delta": 0, "summary": "一句话摘要,30字以内", "risks": ["风险或问题1"], "next_steps": ["下一步计划1"], "updates": [{"content": "本期执行情况", "progress": 60, "stage": "developing", "revenue_delta": 0}] } 规则: 1. stage 只能是 lead / signed / developing / delivered / accepted / revenue / paused / closed 之一。 2. amount 是合同或订单金额(数字,单位元);原文没有就给 null。 3. revenue_amount 是累计已计收金额;revenue_delta 是本期新增计收;没有就给 0 或 null。 4. progress 是开发进度 0-100 的整数,仅在 developing 阶段有意义,其它阶段可按语义填 100。 5. 不要编造原文没有的金额和数字。 """ CUSTOM_USER_TEMPLATE = """定制项目:{project} 周期:{period} 当前阶段:{stage} ---- 原始内容 ---- {raw} ---- 结束 ---- """ _AMT_RE = re.compile(r"(?:金额|合同额|合同金额|下单|订单|报价)?\s*[¥¥]?\s*(\d[\d,,]*(?:\.\d+)?)\s*(元|万|万元)?") _REVENUE_RE = re.compile(r"(?:计收|已计收|核销|回款)[^0-9]{0,8}([0-9][0-9,,]*(?:\.\d+)?)") def _to_number(s: str | None) -> float | None: if s is None: return None try: return float(str(s).replace(",", "").replace(",", "")) except ValueError: return None def _extract_amount(text: str) -> float | None: """从『金额 150,000 元』『下单 39,400.00』等表述中提取金额。""" m = re.search(r"(?:金额|合同额|合同金额|下单|订单|中标|报价)\s*[::为]?\s*[¥¥]?\s*([0-9][0-9,,]*(?:\.[0-9]+)?)", text) if not m: return None v = _to_number(m.group(1)) if v is None: return None tail = text[m.end() : m.end() + 2] if "万" in tail: v *= 10000 return v def _extract_revenue(text: str) -> float | None: m = _REVENUE_RE.search(text or "") if not m: return None v = _to_number(m.group(1)) if v is None: return None tail = (text or "")[m.end() : m.end() + 2] if "万" in tail: v *= 10000 return v def rule_parse_custom(raw: str, project_name: str = "") -> dict[str, Any]: from .constants import guess_custom_stage flat = (raw or "").replace("\n", " ") stage = guess_custom_stage(flat) progress = _guess_progress(flat) items = _split_items(raw) updates = [] for it in items[:12]: updates.append( { "content": it, "progress": _guess_progress(it), "stage": guess_custom_stage(it), "revenue_delta": _extract_revenue(it), } ) risks, next_steps = [], [] for ln in (raw or "").splitlines(): ln = ln.strip() if not ln: continue if any(w in ln for w in ("风险", "阻塞", "问题", "待协调", "无法", "延期", "延迟")): risks.append(ln[:120]) if any(w in ln for w in ("下一步", "下周", "计划", "后续", "待办")): next_steps.append(ln[:120]) if stage in ("delivered", "accepted", "revenue", "closed") and progress is None: progress = 100 if stage == "developing" and progress is None: progress = 30 return { "stage": stage, "progress": progress, "amount": _extract_amount(flat), "revenue_amount": None, "revenue_delta": _extract_revenue(flat), "summary": (items[0].replace("\n", " ").strip()[:60] if items else ""), "risks": risks[:5], "next_steps": next_steps[:5], "updates": updates, } def llm_parse_custom(raw: str, project_name: str, period_label: str, stage: str, db) -> dict[str, Any]: s = get_ai_settings(db) base = (s.get("ai_api_base") or "").rstrip("/") payload = { "model": s.get("ai_model") or "deepseek-chat", "messages": [ {"role": "system", "content": CUSTOM_SYSTEM_PROMPT}, { "role": "user", "content": CUSTOM_USER_TEMPLATE.format( project=project_name or "(未指定)", period=period_label or "(未指定)", stage=stage or "(未知)", raw=raw[:12000], ), }, ], "temperature": float(s.get("ai_temperature") or 0.2), "stream": False, } headers = { "Authorization": f"Bearer {s.get('ai_api_key') or ''}", "Content-Type": "application/json", } with httpx.Client(timeout=config.AI_TIMEOUT) as client: resp = client.post(f"{base}/chat/completions", json=payload, headers=headers) resp.raise_for_status() data = resp.json() content = data["choices"][0]["message"]["content"] parsed = _extract_json(content) parsed.setdefault("updates", []) parsed.setdefault("risks", []) parsed.setdefault("next_steps", []) parsed["model"] = s.get("ai_model") return parsed def parse_custom( raw: str, project_name: str, period_label: str, stage: str, db, use_ai: bool = True ) -> tuple[dict, str, str | None]: if use_ai and ai_enabled(db): try: return llm_parse_custom(raw, project_name, period_label, stage, db), "ai", None except Exception as exc: # noqa: BLE001 r = rule_parse_custom(raw, project_name) r["_warning"] = f"AI 调用失败,已使用本地规则解析:{exc}" return r, "rule", str(exc) r = rule_parse_custom(raw, project_name) if not ai_enabled(db): r["_warning"] = "未配置大模型 API,当前使用本地规则解析(可在「设置」中填写 API 后切换到 AI 整理)。" return r, "rule", None def parse(raw: str, project_name: str, period_label: str, db, use_ai: bool = True) -> tuple[dict, str, str | None]: """返回 (解析结果, 模式, 错误信息)。""" if use_ai and ai_enabled(db): try: return llm_parse(raw, project_name, period_label, db), "ai", None except Exception as exc: # noqa: BLE001 fallback = rule_parse(raw, project_name) fallback["_warning"] = f"AI 调用失败,已使用本地规则解析:{exc}" return fallback, "rule", str(exc) result = rule_parse(raw, project_name) if not ai_enabled(db): result["_warning"] = "未配置大模型 API,当前使用本地规则解析(可在「设置」中填写 API 后切换到 AI 整理)。" return result, "rule", None