dev #1

Merged
kx_guo merged 24 commits from dev into main 2026-09-21 10:51:18 +08:00
10 changed files with 121 additions and 37 deletions
Showing only changes of commit 724a5a6453 - Show all commits

View File

@ -24,7 +24,9 @@ python scripts/create_user.py --emp-no admin001 --name 管理员 --role admin --
OCR 接口:`POST /api/v1/ocr/recognize` 接收最多 5 个 JPG、PNG 或 PDF 文件,图片调用百度 WebImage,PDF 调用 PaddleOCR-VL 文档解析;返回可编辑字段、检测数值、置信度及标准库判定。`POST /api/v1/ocr/judge` 用于人工修正检测值后重新判定,`GET /api/v1/ocr/files/{file_id}` 通过登录鉴权读取原始附件。启用前配置 `BAIDU_OCR_API_KEY` 和 `BAIDU_OCR_SECRET_KEY`,并执行数据库迁移。
角色采用管理员 `admin`、产品经理 `product_manager`、审核员 `reviewer`、普通用户 `普通用户` 四类。模型权重由管理员通过 `GET/PATCH /api/v1/model-config` 维护,七维权重合计必须为 100%,修改仅对新评估生效。
角色采用管理员 `admin`、产品经理 `product_manager`、审核员 `reviewer`、普通用户 `user` 四类。模型权重由管理员通过 `GET/PATCH /api/v1/model-config` 维护,七维权重合计必须为 100%,修改仅对新评估生效。
钉钉免登用户按两级规则同步:钉钉管理员或部门主管映射为 `admin`(已包含审核权限),其他钉钉用户统一映射为 `user`。`product_manager` 和 `reviewer` 仅保留给非钉钉账号或后续独立用户管理流程使用。
健康检查:`GET http://127.0.0.1:18474/api/v1/health`

View File

@ -156,7 +156,7 @@ class ConversationService:
assistant_message = ConversationMessage(
conversation_id=conversation.id,
role="assistant",
content="已根据当前描述完成风险预估,下面是本轮分析结果。你可以继续补充产品、供应商或标准信息。",
content="已根据当前描述完成风险预警分析,下面是本轮分析结果。你可以继续补充产品、供应商或标准信息。",
structured_data=(
{"riskResult": risk_result.model_dump(mode="json", by_alias=False)}
if risk_result
@ -320,7 +320,7 @@ class ConversationService:
{
"role": "system",
"content": (
"你是蝶雅产品风险预估助手。请基于用户当前问题和历史上下文回答,"
"你是蝶雅产品风险预警助手。请基于用户当前问题和历史上下文回答,"
"持续理解产品品类、规格、包材、供应商、加工厂和开发阶段。"
"回答要具体、简洁、可执行;不要声称已经完成 RAG、OCR 或语音识别。"
),

View File

@ -125,10 +125,8 @@ class DingTalkAuthService:
role = self.map_role(
str(detail.get("dept_name") or detail.get("deptName") or ""),
str(detail.get("title") or detail.get("position") or ""),
bool(detail.get("is_admin") or detail.get("is_dept_manager")),
self._is_dingtalk_manager(detail),
)
if user is not None and role == "普通用户" and user.role != "普通用户":
role = user.role
if user is None:
user = UserAccount(
emp_no=f"dt_{user_id}"[:20],
@ -153,11 +151,7 @@ class DingTalkAuthService:
user.dingtalk_dept_id = int(dept_id) if dept_id is not None else None
except (TypeError, ValueError):
user.dingtalk_dept_id = None
user.dingtalk_is_dept_manager = bool(
detail.get("is_admin")
or detail.get("is_dept_manager")
or user.dingtalk_is_dept_manager
)
user.dingtalk_is_dept_manager = self._is_dingtalk_manager(detail)
role_codes = detail.get("role_list") or user.dingtalk_role_codes
if isinstance(role_codes, (list, dict)):
role_codes = json.dumps(role_codes, ensure_ascii=False)
@ -170,15 +164,35 @@ class DingTalkAuthService:
@staticmethod
def map_role(dept: str, title: str, is_admin: bool = False) -> str:
"""Map DingTalk organization snapshots to the four-role administration model."""
"""Map DingTalk users to admin (including review) or regular user."""
text = f"{dept} {title}".lower()
if is_admin or "it" in text or "信息" in text or "系统" in text:
return "admin"
if "审核" in text or "review" in text:
return "reviewer"
if "产品经理" in text or "product manager" in text or (
"产品" in dept and ("经理" in title or "主管" in title)
):
return "product_manager"
return "普通用户"
return "admin" if is_admin else "user"
@staticmethod
def _is_dingtalk_manager(detail: dict) -> bool:
"""Read administrator/department-leader flags from DingTalk API variants."""
direct = (
detail.get("admin"),
detail.get("is_admin"),
detail.get("isAdmin"),
detail.get("is_dept_manager"),
detail.get("isDeptManager"),
)
if any(value is True or str(value).lower() in {"1", "true"} for value in direct if value is not None):
return True
leaders = detail.get("leader_in_dept") or detail.get("leaderInDept") or []
if isinstance(leaders, dict):
leaders = [leaders]
if isinstance(leaders, list):
return any(
item is True
or str(item).lower() in {"1", "true"}
or isinstance(item, dict)
and any(
value is True or str(value).lower() in {"1", "true"}
for value in item.values()
)
for item in leaders
)
return False

View File

@ -89,7 +89,7 @@ class LLMClient:
{
"role": "system",
"content": (
"你是蝶雅产品风险预估系统的语义理解模块。"
"你是蝶雅产品风险预警系统的语义理解模块。"
"只输出合法 JSON,不要输出 Markdown。请输出 json object。抽取用户输入中的意图和风险评估参数。"
"intent 只能是 greet、general、assessment、supplier、material、cases、"
"standard、compliance、solution 之一。"
@ -142,7 +142,7 @@ class LLMClient:
{
"role": "system",
"content": (
"你是蝶雅产品风险预估系统的OCR字段抽取模块,只输出合法JSON对象。"
"你是蝶雅产品风险预警系统的OCR字段抽取模块,只输出合法JSON对象。"
"只能提取输入文字明确出现的信息,不得推测或补造。输出字段:product_name、product_category、"
"supplier、factory、material_type、stage_new、description、defect_level、occurred_on、confidence、"
"measurements。缺失标量用null。defect_level只能是A、B、C或null;occurred_on使用YYYY-MM-DD或null。"
@ -199,7 +199,7 @@ class LLMClient:
{
"role": "system",
"content": (
"你是蝶雅产品风险预估系统的预防方案生成模块。"
"你是蝶雅产品风险预警系统的预防方案生成模块。"
"请根据提供的风险评估数据生成可执行的质量预防建议。"
"只输出合法 json object,不要输出 Markdown,格式必须是 "
'{"suggestions":["建议1","建议2"]}。'

View File

@ -258,8 +258,8 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes:
standard_cards = report_data["standard_cards"]
section_shift = 1 if material_cards else 0
story = [
Paragraph("新品风险预估报告", title),
Paragraph(f"蝶雅产品部 · 风险预估系统 · 自动生成 · {generated_at}", subtitle),
Paragraph("新品风险预警报告", title),
Paragraph(f"蝶雅产品部 · 风险预警系统 · 自动生成 · {generated_at}", subtitle),
Paragraph(
f"产品/评估对象:{result.subject_id}  供应商:{supplier_name or '未指定'}  报告编号:{report_number}",
body,
@ -451,7 +451,7 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes:
leftMargin=18 * mm,
topMargin=16 * mm,
bottomMargin=16 * mm,
title="蝶雅新品风险预估报告",
title="蝶雅新品风险预警报告",
)
doc.build(story)
return output.getvalue()
@ -471,12 +471,12 @@ def build_risk_report_docx(result: RiskAssessmentResult) -> bytes:
normal.font.size = Pt(9)
title = document.add_paragraph()
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = title.add_run("新品风险预估报告")
run = title.add_run("新品风险预警报告")
run.bold = True
run.font.size = Pt(20)
subtitle = document.add_paragraph()
subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER
subtitle.add_run(f"蝶雅产品部 · 风险预估系统 · 自动生成 · {result.subject_id}").font.size = Pt(9)
subtitle.add_run(f"蝶雅产品部 · 风险预警系统 · 自动生成 · {result.subject_id}").font.size = Pt(9)
report_data = aggregate_report_data(result)
standards = report_data["standards"]
similar_cases = report_data["similar_cases"]

View File

@ -0,0 +1,31 @@
"""Normalize the final Chinese system role code to English."""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260911_0020"
down_revision: str | None = "20260910_0019"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
connection = op.get_bind()
connection.execute(
sa.text("UPDATE user_accounts SET role = 'user', updated_at = now() WHERE role = '普通用户'")
)
connection.execute(
sa.text("UPDATE role_permissions SET role = 'user', updated_at = now() WHERE role = '普通用户'")
)
def downgrade() -> None:
connection = op.get_bind()
connection.execute(
sa.text("UPDATE user_accounts SET role = '普通用户', updated_at = now() WHERE role = 'user'")
)
connection.execute(
sa.text("UPDATE role_permissions SET role = '普通用户', updated_at = now() WHERE role = 'user'")
)

View File

@ -0,0 +1,31 @@
"""Apply the admin/user policy to existing real DingTalk accounts."""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260911_0021"
down_revision: str | None = "20260911_0020"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.get_bind().execute(
sa.text(
"""
UPDATE user_accounts
SET role = CASE WHEN dingtalk_is_dept_manager THEN 'admin' ELSE 'user' END,
updated_at = now()
WHERE dingtalk_id IS NOT NULL
AND dingtalk_id NOT LIKE 'local-%'
"""
)
)
def downgrade() -> None:
# The previous product_manager/reviewer assignment cannot be reconstructed
# reliably; keep the current safe role instead of guessing on downgrade.
pass

View File

@ -47,7 +47,7 @@ def main() -> None:
parser.add_argument(
"--role",
default="admin",
choices=("普通用户", "dev", "procurement", "qc", "ops", "reviewer", "manager", "admin"),
choices=("user", "product_manager", "reviewer", "admin"),
)
parser.add_argument("--email")
args = parser.parse_args()

View File

@ -2,9 +2,15 @@ from app.services.dingtalk_auth import DingTalkAuthService
def test_dingtalk_role_mapping() -> None:
assert DingTalkAuthService.map_role("产品部", "产品经理") == "product_manager"
assert DingTalkAuthService.map_role("品控部", "质量主管") == "普通用户"
assert DingTalkAuthService.map_role("采购部", "采购专员") == "普通用户"
assert DingTalkAuthService.map_role("", "审核员") == "reviewer"
assert DingTalkAuthService.map_role("产品部", "产品经理") == "user"
assert DingTalkAuthService.map_role("品控部", "质量主管") == "user"
assert DingTalkAuthService.map_role("采购部", "采购专员") == "user"
assert DingTalkAuthService.map_role("", "审核员") == "user"
assert DingTalkAuthService.map_role("", "", is_admin=True) == "admin"
assert DingTalkAuthService.map_role("未知部门", "普通员工") == "普通用户"
assert DingTalkAuthService.map_role("未知部门", "普通员工") == "user"
def test_dingtalk_manager_flag_variants() -> None:
assert DingTalkAuthService._is_dingtalk_manager({"admin": True})
assert DingTalkAuthService._is_dingtalk_manager({"leader_in_dept": [{"leader": True}]})
assert not DingTalkAuthService._is_dingtalk_manager({"admin": False, "leader_in_dept": []})

View File

@ -11,7 +11,7 @@ def test_conversation_message_data_reads_persisted_risk_result() -> None:
id=10,
conversation_id=3,
role="assistant",
content="已完成风险预估。",
content="已完成风险预警。",
structured_data={
"riskResult": {
"subjectId": "软管新品",