413 lines
15 KiB
Python
413 lines
15 KiB
Python
"""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
|