unis_manager/app/ai_parser.py

413 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""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