commit ec8b83d0153036898ba592ac0e3365022ca1209f Author: kx_guo Date: Fri Sep 4 10:20:40 2026 +0800 风险预警 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ef2d8a7 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +RISK_APP_NAME=Dieya Risk Prediction Backend +RISK_APP_VERSION=0.1.0 +RISK_ENVIRONMENT=development +RISK_DEBUG=false +RISK_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/dieya_risk_prediction +RISK_LOG_LEVEL=INFO +RISK_JWT_SECRET_KEY=CHANGE_THIS_IN_PRODUCTION +RISK_JWT_ALGORITHM=HS256 +RISK_JWT_EXPIRE_MINUTES=480 +RISK_DINGTALK_APP_KEY= +RISK_DINGTALK_APP_SECRET= +RISK_DINGTALK_CORP_ID= +RISK_DINGTALK_AGENT_ID= +RISK_LLM_API_KEY= +RISK_LLM_BASE_URL=https://api.openai.com/v1 +RISK_LLM_MODEL=gpt-4o-mini +RISK_LLM_TIMEOUT_SECONDS=120 +RISK_LLM_TEMPERATURE=0.2 +RISK_CORS_ALLOW_ORIGINS=http://localhost:39158,http://127.0.0.1:39158 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e156bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ +.env +*.egg-info/ +dist/ +build/ +data/ + diff --git a/.runtime-logs/backend.err.log b/.runtime-logs/backend.err.log new file mode 100644 index 0000000..df0e696 --- /dev/null +++ b/.runtime-logs/backend.err.log @@ -0,0 +1,12 @@ +INFO: Started server process [34308] +INFO: Waiting for application startup. +INFO: Application startup complete. +INFO: Uvicorn running on http://127.0.0.1:18474 (Press CTRL+C to quit) +D:\py_qoder\dieya_risk_prediction_backend\.venv\Lib\site-packages\jwt\api_jwt.py:147: InsecureKeyLengthWarning: The HMAC key is 25 bytes long, which is below the minimum recommended length of 32 bytes for SHA256. See RFC 7518 Section 3.2. + return self._jws.encode( +D:\py_qoder\dieya_risk_prediction_backend\.venv\Lib\site-packages\jwt\api_jwt.py:368: InsecureKeyLengthWarning: The HMAC key is 25 bytes long, which is below the minimum recommended length of 32 bytes for SHA256. See RFC 7518 Section 3.2. + decoded = self.decode_complete( +D:\py_qoder\dieya_risk_prediction_backend\.venv\Lib\site-packages\jwt\api_jwt.py:147: InsecureKeyLengthWarning: The HMAC key is 25 bytes long, which is below the minimum recommended length of 32 bytes for SHA256. See RFC 7518 Section 3.2. + return self._jws.encode( +D:\py_qoder\dieya_risk_prediction_backend\.venv\Lib\site-packages\jwt\api_jwt.py:368: InsecureKeyLengthWarning: The HMAC key is 25 bytes long, which is below the minimum recommended length of 32 bytes for SHA256. See RFC 7518 Section 3.2. + decoded = self.decode_complete( diff --git a/.runtime-logs/backend.out.log b/.runtime-logs/backend.out.log new file mode 100644 index 0000000..8a21862 --- /dev/null +++ b/.runtime-logs/backend.out.log @@ -0,0 +1,26 @@ +INFO: 127.0.0.1:58350 - "POST /api/v1/auth/dev-login HTTP/1.1" 200 OK +INFO: 127.0.0.1:58355 - "GET /api/v1/dashboard?year=all HTTP/1.1" 200 OK +INFO: 127.0.0.1:58521 - "GET /api/v1/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:58524 - "GET /api/v1/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:58522 - "GET /api/v1/conversations?limit=50 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58523 - "GET /api/v1/dashboard?year=all HTTP/1.1" 200 OK +INFO: 127.0.0.1:58886 - "GET /api/v1/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:58889 - "GET /api/v1/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:58887 - "GET /api/v1/conversations?limit=50 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58888 - "GET /api/v1/dashboard?year=all HTTP/1.1" 200 OK +INFO: 127.0.0.1:58941 - "GET /api/v1/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:58946 - "GET /api/v1/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:58944 - "GET /api/v1/conversations?limit=50 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58945 - "GET /api/v1/dashboard?year=all HTTP/1.1" 200 OK +INFO: 127.0.0.1:61473 - "GET /api/v1/auth/me HTTP/1.1" 401 Unauthorized +INFO: 127.0.0.1:62724 - "POST /api/v1/auth/dev-login HTTP/1.1" 200 OK +INFO: 127.0.0.1:62735 - "GET /api/v1/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:62734 - "GET /api/v1/conversations?limit=50 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62739 - "GET /api/v1/conversations?limit=50 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62750 - "GET /api/v1/dashboard?year=all HTTP/1.1" 200 OK +INFO: 127.0.0.1:65438 - "POST /api/v1/auth/dev-login HTTP/1.1" 200 OK +INFO: 127.0.0.1:65451 - "GET /api/v1/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:65462 - "GET /api/v1/standards?category=all&status=%E5%90%AF%E7%94%A8&page=1&pageSize=20 HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:65450 - "GET /api/v1/conversations?limit=50 HTTP/1.1" 200 OK +INFO: 127.0.0.1:65467 - "GET /api/v1/standards?category=all&status=%E5%90%AF%E7%94%A8&page=1&pageSize=20 HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:65516 - "GET /api/v1/standards HTTP/1.1" 404 Not Found diff --git a/README.md b/README.md new file mode 100644 index 0000000..b155f27 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# Dieya Risk Prediction Backend + +本项目参考 `dy_finance_agent_backend` 的分层方式,使用 Python 3.11、FastAPI、Pydantic v2、SQLAlchemy 2.x Async、PostgreSQL 和 Alembic。 + +## 本地启动 + +```powershell +.\.venv\Scripts\Activate.ps1 +python -m pip install -e ".[dev]" +python -m uvicorn app.main:app --host 127.0.0.1 --port 18474 +``` + +创建本地密码账号(密码只保存哈希): + +```powershell +python scripts/create_user.py --emp-no admin001 --name 管理员 --role admin --password "请替换为强密码" --email admin@dieya.com +``` + +钉钉免登需要额外配置 `RISK_DINGTALK_APP_KEY`、`RISK_DINGTALK_APP_SECRET`、`RISK_DINGTALK_CORP_ID` 和 `RISK_DINGTALK_AGENT_ID`;前端在钉钉容器中调用 `requestAuthCode()`,后端换码后签发本系统 JWT。 + +认证接口:`POST /api/v1/auth/login`、`GET /api/v1/auth/me`、`GET /api/v1/auth/dingtalk/config`、`POST /api/v1/auth/dingtalk/login`。 + +审核接口:`POST /api/v1/risk-items`、`GET /api/v1/risk-items/pending`、`POST /api/v1/risk-items/{id}/review`。 + +健康检查:`GET http://127.0.0.1:18474/api/v1/health` + +数据库表结构和字段释义通过 Alembic 管理。执行 `python -m alembic upgrade head` 会创建或更新表、索引、外键及 PostgreSQL 表/字段注释。 + +导入 PRD 数据: + +```powershell +python -m alembic upgrade head +python scripts/import_excel_data.py ` + --anomaly-file "C:\Users\Administrator\Desktop\异常数据汇总表.xlsx" ` + --standard-file "C:\Users\Administrator\Desktop\标准文件.xlsx" +``` + +脚本只导入异常明细 Sheet 的 2,448 条记录,以及标准文件中的“包装/包材/法规/内料”四个分类 Sheet 共 219 条记录;“总表”不重复导入。脚本可重复执行,已导入行会按源文件、Sheet 和 Excel 行号自动跳过。原始异常文本和标准附件链接会完整保留,不做截断。 + +风险评估预览:`POST http://127.0.0.1:18474/api/v1/risk-predictions/preview`。指标值表示风险程度(0-100),接口返回的综合分按 PRD 转换为安全得分,分数越高风险越低;等级阈值为 80/60/40/20。 + +对话页真实 AI:配置 `RISK_LLM_API_KEY`、`RISK_LLM_BASE_URL` 和 `RISK_LLM_MODEL` 后,调用 `POST /api/v1/conversations/{id}/stream`。接口使用 OpenAI 兼容 Chat Completions 协议,先提取 `intent/category/specification/packaging/supplier/factory/stage/confidence` 语义 JSON,再通过 SSE 返回增量文本;最近 20 条消息作为上下文,完整助手消息、语义结果和风险评分会在流结束后持久化。也兼容参考项目的 `OPENAI_API_KEY`、`LLM_BASE_URL`、`LLM_MODEL` 配置名。未配置密钥时返回明确的 503,不生成模拟回复。 + +架构说明见 [docs/architecture.md](docs/architecture.md)。 diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..c73b4fc --- /dev/null +++ b/alembic.ini @@ -0,0 +1,37 @@ +[alembic] +script_location = %(here)s/migrations +prepend_sys_path = . +sqlalchemy.url = + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s + diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..bd2fcf2 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,2 @@ +"""Dieya risk prediction backend package.""" + diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..b05dfd6 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,2 @@ +"""API package.""" + diff --git a/app/api/dependencies.py b/app/api/dependencies.py new file mode 100644 index 0000000..d8de51b --- /dev/null +++ b/app/api/dependencies.py @@ -0,0 +1,59 @@ +"""Shared FastAPI dependencies for database access and authorization.""" + +from collections.abc import Callable +from typing import Annotated + +from fastapi import Depends +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import AppException +from app.core.security import decode_access_token +from app.database.session import get_db_session +from app.models.entities import UserAccount +from app.repositories.users import UserRepository + +DatabaseSession = Annotated[AsyncSession, Depends(get_db_session)] +_bearer = HTTPBearer(auto_error=False) + + +async def get_current_user( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)], + session: DatabaseSession, +) -> UserAccount: + """Decode the JWT and re-check account status on every request.""" + + if credentials is None: + raise AppException("缺少认证令牌", code=401, status_code=401) + payload = decode_access_token(credentials.credentials) + if not payload or not payload.get("sub"): + raise AppException("无效或过期的认证令牌", code=401, status_code=401) + try: + user_id = int(payload["sub"]) + except (TypeError, ValueError) as exc: + raise AppException("无效的认证令牌", code=401, status_code=401) from exc + user = await UserRepository(session).get_by_id(user_id) + if user is None or user.status != "启用": + raise AppException("用户不存在或已停用", code=401, status_code=401) + return user + + +def require_permission(func_key: str, *, operate: bool = False) -> Callable[..., UserAccount]: + """Create an RBAC dependency checked against the current database state.""" + + async def dependency( + current_user: Annotated[UserAccount, Depends(get_current_user)], + session: DatabaseSession, + ) -> UserAccount: + if current_user.role == "admin": + return current_user + statement_user = UserRepository(session) + permissions = await statement_user.permissions_for_role(current_user.role, operate=operate) + allowed = { + permission.func_key for permission in permissions + } + if func_key not in allowed: + raise AppException("无权执行该操作", code=403, status_code=403) + return current_user + + return dependency diff --git a/app/api/router.py b/app/api/router.py new file mode 100644 index 0000000..944db51 --- /dev/null +++ b/app/api/router.py @@ -0,0 +1,20 @@ +"""Versioned API router composition.""" + +from fastapi import APIRouter + +from app.api.v1.auth import router as auth_router +from app.api.v1.conversations import router as conversations_router +from app.api.v1.dashboard import router as dashboard_router +from app.api.v1.health import router as health_router +from app.api.v1.risk_items import router as risk_items_router +from app.api.v1.risk_predictions import router as risk_predictions_router +from app.api.v1.standards import router as standards_router + +api_router = APIRouter(prefix="/api/v1") +api_router.include_router(health_router) +api_router.include_router(auth_router) +api_router.include_router(conversations_router) +api_router.include_router(dashboard_router) +api_router.include_router(risk_items_router) +api_router.include_router(risk_predictions_router) +api_router.include_router(standards_router) diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..f221560 --- /dev/null +++ b/app/api/v1/__init__.py @@ -0,0 +1,2 @@ +"""API version 1 package.""" + diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py new file mode 100644 index 0000000..877470a --- /dev/null +++ b/app/api/v1/auth.py @@ -0,0 +1,72 @@ +"""Password, development, and DingTalk authentication endpoints.""" + +from datetime import UTC, datetime +from typing import Annotated + +from fastapi import APIRouter, Depends + +from app.api.dependencies import DatabaseSession, get_current_user +from app.core.config import get_settings +from app.core.exceptions import AppException +from app.core.security import create_access_token, hash_password +from app.models.entities import OperationAudit, UserAccount +from app.repositories.users import UserRepository +from app.schemas.auth import DingTalkConfigData, DingTalkLoginRequest, LoginRequest, TokenData, UserData +from app.schemas.common import ApiResponse +from app.services.auth import AuthService +from app.services.dingtalk_auth import DingTalkAuthService + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/login", response_model=ApiResponse[TokenData]) +async def login(request: LoginRequest, session: DatabaseSession) -> ApiResponse[TokenData]: + return ApiResponse(data=await AuthService(session).login(request.username, request.password)) + + +@router.get("/me", response_model=ApiResponse[UserData]) +async def me( + current_user: Annotated[UserAccount, Depends(get_current_user)], + session: DatabaseSession, +) -> ApiResponse[UserData]: + return ApiResponse(data=await AuthService(session).me(current_user)) + + +@router.get("/dingtalk/config", response_model=ApiResponse[DingTalkConfigData]) +async def dingtalk_config() -> ApiResponse[DingTalkConfigData]: + return ApiResponse(data=DingTalkAuthService.config()) + + +@router.post("/dingtalk/login", response_model=ApiResponse[TokenData]) +async def dingtalk_login(request: DingTalkLoginRequest, session: DatabaseSession) -> ApiResponse[TokenData]: + return ApiResponse(data=await DingTalkAuthService(session).login(request.auth_code)) + + +@router.post("/dev-login", response_model=ApiResponse[TokenData]) +async def dev_login(request: LoginRequest, session: DatabaseSession) -> ApiResponse[TokenData]: + """Development-only login helper used by local frontend verification.""" + + settings = get_settings() + if settings.environment != "development": + raise AppException("开发登录入口未启用", code=404, status_code=404) + user = await UserRepository(session).get_by_dingtalk_id(f"local-{request.username}") + if user is None: + user = UserAccount( + emp_no=f"dev_{request.username}"[:20], + name="开发测试用户", + role="admin", + status="启用", + dingtalk_id=f"local-{request.username}", + password_hash=hash_password(request.password), + ) + session.add(user) + await session.flush() + user.last_login_at = datetime.now(UTC) + session.add(OperationAudit(actor_user_id=user.id, actor_emp_no=user.emp_no, action="auth.dev_login")) + return ApiResponse( + data=TokenData( + access_token=create_access_token(user.id, role=user.role), + expires_in=settings.jwt_expire_minutes * 60, + user=await AuthService(session).me(user), + ) + ) diff --git a/app/api/v1/conversations.py b/app/api/v1/conversations.py new file mode 100644 index 0000000..d4bac29 --- /dev/null +++ b/app/api/v1/conversations.py @@ -0,0 +1,102 @@ +"""Persistent assistant conversation endpoints.""" + +import json +from typing import Annotated + +from fastapi import APIRouter, Depends, Query +from fastapi.responses import StreamingResponse + +from app.api.dependencies import DatabaseSession, require_permission +from app.models.entities import UserAccount +from app.schemas.common import ApiResponse +from app.schemas.conversation import ( + ConversationCreateRequest, + ConversationData, + ConversationMessageCreateRequest, + ConversationSummary, +) +from app.services.conversations import ConversationService + +router = APIRouter(prefix="/conversations", tags=["conversations"]) + + +@router.get("", response_model=ApiResponse[list[ConversationSummary]]) +async def list_conversations( + session: DatabaseSession, + current_user: Annotated[UserAccount, Depends(require_permission("risk.assess"))], + limit: int = Query(50, ge=1, le=100), + search: str | None = Query(default=None, max_length=120), +) -> ApiResponse[list[ConversationSummary]]: + return ApiResponse(data=await ConversationService(session).list(current_user, limit=limit, search=search)) + + +@router.post("", response_model=ApiResponse[ConversationData]) +async def create_conversation( + payload: ConversationCreateRequest, + session: DatabaseSession, + current_user: Annotated[UserAccount, Depends(require_permission("risk.assess", operate=True))], +) -> ApiResponse[ConversationData]: + return ApiResponse(data=await ConversationService(session).create(current_user, payload.title)) + + +@router.get("/{conversation_id}", response_model=ApiResponse[ConversationData]) +async def get_conversation( + conversation_id: int, + session: DatabaseSession, + current_user: Annotated[UserAccount, Depends(require_permission("risk.assess"))], + limit: int = Query(20, ge=1, le=20), + offset: int = Query(0, ge=0), +) -> ApiResponse[ConversationData]: + return ApiResponse( + data=await ConversationService(session).get( + conversation_id, + current_user, + limit=limit, + offset=offset, + ) + ) + + +@router.post("/{conversation_id}/messages", response_model=ApiResponse[ConversationData]) +async def send_conversation_message( + conversation_id: int, + payload: ConversationMessageCreateRequest, + session: DatabaseSession, + current_user: Annotated[UserAccount, Depends(require_permission("risk.assess", operate=True))], +) -> ApiResponse[ConversationData]: + return ApiResponse( + data=await ConversationService(session).send( + conversation_id, + payload.content, + current_user, + message_type=payload.message_type, + ), + ) + + +@router.post("/{conversation_id}/stream") +async def stream_conversation_message( + conversation_id: int, + payload: ConversationMessageCreateRequest, + session: DatabaseSession, + current_user: Annotated[UserAccount, Depends(require_permission("risk.assess", operate=True))], +) -> StreamingResponse: + service = ConversationService(session) + + async def events(): + try: + async for event in service.stream( + conversation_id, + payload.content, + current_user, + message_type=payload.message_type, + ): + yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" + except Exception as exc: # noqa: BLE001 - turn route errors into SSE events + yield f"data: {json.dumps({'type': 'error', 'message': str(exc)}, ensure_ascii=False)}\n\n" + + return StreamingResponse( + events(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) diff --git a/app/api/v1/dashboard.py b/app/api/v1/dashboard.py new file mode 100644 index 0000000..6dde290 --- /dev/null +++ b/app/api/v1/dashboard.py @@ -0,0 +1,22 @@ +"""Dashboard endpoints.""" + +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends, Query + +from app.api.dependencies import DatabaseSession, require_permission +from app.models.entities import UserAccount +from app.schemas.common import ApiResponse +from app.schemas.dashboard import DashboardData +from app.services.dashboard import DashboardService + +router = APIRouter(prefix="/dashboard", tags=["dashboard"]) + + +@router.get("", response_model=ApiResponse[DashboardData]) +async def get_dashboard( + session: DatabaseSession, + _current_user: Annotated[UserAccount, Depends(require_permission("risk.assess"))], + year: Literal["all", "2024", "2025", "2026"] = Query("all"), +) -> ApiResponse[DashboardData]: + return ApiResponse(data=await DashboardService(session).overview(year)) diff --git a/app/api/v1/health.py b/app/api/v1/health.py new file mode 100644 index 0000000..8d185b4 --- /dev/null +++ b/app/api/v1/health.py @@ -0,0 +1,16 @@ +"""Service health endpoint.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends + +from app.core.config import Settings, get_settings +from app.schemas.common import ApiResponse, HealthData + +router = APIRouter(tags=["health"]) + + +@router.get("/health", response_model=ApiResponse[HealthData]) +async def health(settings: Annotated[Settings, Depends(get_settings)]) -> ApiResponse[HealthData]: + return ApiResponse(data=HealthData(service=settings.app_name, version=settings.app_version)) + diff --git a/app/api/v1/risk_items.py b/app/api/v1/risk_items.py new file mode 100644 index 0000000..54279cd --- /dev/null +++ b/app/api/v1/risk_items.py @@ -0,0 +1,59 @@ +"""Risk item submission and review endpoints.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Query + +from app.api.dependencies import DatabaseSession, require_permission +from app.models.entities import UserAccount +from app.schemas.common import ApiResponse +from app.schemas.risk_item import ReviewActionRequest, ReviewRecordData, RiskItemCreate, RiskItemData, RiskItemUpdate +from app.services.risk_items import RiskItemService + +router = APIRouter(prefix="/risk-items", tags=["risk-items"]) + + +@router.post("", response_model=ApiResponse[RiskItemData]) +async def create_risk_item( + payload: RiskItemCreate, + session: DatabaseSession, + current_user: Annotated[UserAccount, Depends(require_permission("risk_item.submit", operate=True))], +) -> ApiResponse[RiskItemData]: + return ApiResponse(data=await RiskItemService(session).submit(payload, current_user)) + + +@router.get("/pending", response_model=ApiResponse[list[RiskItemData]]) +async def pending_risk_items( + session: DatabaseSession, + _current_user: Annotated[UserAccount, Depends(require_permission("risk_item.review"))], + limit: int = Query(100, ge=1, le=100), + source_type: str = Query("all"), + status: str = Query("待审核"), +) -> ApiResponse[list[RiskItemData]]: + return ApiResponse( + data=await RiskItemService(session).list_pending( + limit, + source_type=source_type, + status=status, + ) + ) + + +@router.patch("/{item_id}", response_model=ApiResponse[RiskItemData]) +async def update_risk_item( + item_id: int, + payload: RiskItemUpdate, + session: DatabaseSession, + current_user: Annotated[UserAccount, Depends(require_permission("risk_item.review", operate=True))], +) -> ApiResponse[RiskItemData]: + return ApiResponse(data=await RiskItemService(session).update(item_id, payload, current_user)) + + +@router.post("/{item_id}/review", response_model=ApiResponse[ReviewRecordData]) +async def review_risk_item( + item_id: int, + payload: ReviewActionRequest, + session: DatabaseSession, + current_user: Annotated[UserAccount, Depends(require_permission("risk_item.review", operate=True))], +) -> ApiResponse[ReviewRecordData]: + return ApiResponse(data=await RiskItemService(session).review(item_id, payload.action, payload.note, current_user)) diff --git a/app/api/v1/risk_predictions.py b/app/api/v1/risk_predictions.py new file mode 100644 index 0000000..5344a1b --- /dev/null +++ b/app/api/v1/risk_predictions.py @@ -0,0 +1,28 @@ +"""Risk prediction endpoints.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends + +from app.api.dependencies import require_permission +from app.models.entities import UserAccount +from app.schemas.common import ApiResponse +from app.schemas.risk_prediction import ModelInfo, RiskAssessmentRequest, RiskAssessmentResult +from app.services.risk_prediction import RiskPredictionService + +router = APIRouter(prefix="/risk-predictions", tags=["risk-predictions"]) + + +@router.get("/model-info", response_model=ApiResponse[ModelInfo]) +async def model_info( + _: Annotated[UserAccount, Depends(require_permission("risk.assess"))], +) -> ApiResponse[ModelInfo]: + return ApiResponse(data=RiskPredictionService.model_info()) + + +@router.post("/preview", response_model=ApiResponse[RiskAssessmentResult]) +async def preview_risk( + request: RiskAssessmentRequest, + _: Annotated[UserAccount, Depends(require_permission("risk.assess", operate=True))], +) -> ApiResponse[RiskAssessmentResult]: + return ApiResponse(data=RiskPredictionService.preview(request)) diff --git a/app/api/v1/standards.py b/app/api/v1/standards.py new file mode 100644 index 0000000..8f8e3c8 --- /dev/null +++ b/app/api/v1/standards.py @@ -0,0 +1,56 @@ +"""Judgment standard library endpoints.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Query + +from app.api.dependencies import DatabaseSession, require_permission +from app.models.entities import UserAccount +from app.schemas.common import ApiResponse +from app.schemas.standards import StandardRuleCreate, StandardRuleDetail, StandardRuleListData, StandardRuleUpdate +from app.services.standards import StandardLibraryService + +router = APIRouter(prefix="/standards", tags=["standards"]) + + +@router.get("", response_model=ApiResponse[StandardRuleListData]) +async def list_standards( + session: DatabaseSession, + _current_user: Annotated[UserAccount, Depends(require_permission("standard.read"))], + keyword: str | None = Query(default=None, max_length=50), + category: str = Query(default="all"), + status: str = Query(default="启用"), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), +) -> ApiResponse[StandardRuleListData]: + return ApiResponse(data=await StandardLibraryService(session).list( + keyword=keyword, category=category, status=status, page=page, page_size=page_size, + )) + + +@router.get("/{rule_id}", response_model=ApiResponse[StandardRuleDetail]) +async def get_standard( + rule_id: int, + session: DatabaseSession, + _current_user: Annotated[UserAccount, Depends(require_permission("standard.read"))], +) -> ApiResponse[StandardRuleDetail]: + return ApiResponse(data=await StandardLibraryService(session).get(rule_id)) + + +@router.post("", response_model=ApiResponse[StandardRuleDetail]) +async def create_standard( + payload: StandardRuleCreate, + session: DatabaseSession, + current_user: Annotated[UserAccount, Depends(require_permission("standard.manage", operate=True))], +) -> ApiResponse[StandardRuleDetail]: + return ApiResponse(data=await StandardLibraryService(session).create(payload, current_user)) + + +@router.patch("/{rule_id}", response_model=ApiResponse[StandardRuleDetail]) +async def update_standard( + rule_id: int, + payload: StandardRuleUpdate, + session: DatabaseSession, + current_user: Annotated[UserAccount, Depends(require_permission("standard.manage", operate=True))], +) -> ApiResponse[StandardRuleDetail]: + return ApiResponse(data=await StandardLibraryService(session).update(rule_id, payload, current_user)) diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..a54de7d --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1,2 @@ +"""Application core package.""" + diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..6bd7f70 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,92 @@ +"""Environment-backed application settings.""" + +from functools import lru_cache +from typing import Annotated + +from pydantic import AliasChoices, Field, SecretStr, field_validator +from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict + + +class Settings(BaseSettings): + app_name: str = "Dieya Risk Prediction Backend" + app_version: str = "0.1.0" + environment: str = "development" + debug: bool = False + database_url: str = Field( + default="postgresql+asyncpg://postgres:postgres@localhost:5432/dieya_risk_prediction", + repr=False, + ) + log_level: str = "INFO" + jwt_secret_key: SecretStr = SecretStr("CHANGE_THIS_IN_PRODUCTION") + jwt_algorithm: str = "HS256" + jwt_expire_minutes: int = Field(default=480, gt=0) + dingtalk_app_key: str = "" + dingtalk_app_secret: SecretStr | None = Field(default=None, repr=False) + dingtalk_corp_id: str = "" + dingtalk_agent_id: str = "" + llm_api_key: SecretStr | None = Field( + default=None, + repr=False, + validation_alias=AliasChoices( + "RISK_LLM_API_KEY", + "OPENAI_API_KEY", + "YUNWU_API_KEY", + ), + ) + llm_base_url: str = Field( + default="https://api.openai.com/v1", + validation_alias=AliasChoices("RISK_LLM_BASE_URL", "LLM_BASE_URL", "OPENAI_API_BASE"), + ) + llm_model: str = Field( + default="gpt-4o-mini", + validation_alias=AliasChoices("RISK_LLM_MODEL", "LLM_MODEL"), + ) + llm_timeout_seconds: int = Field( + default=120, + gt=0, + le=600, + validation_alias=AliasChoices("RISK_LLM_TIMEOUT_SECONDS", "LLM_TIMEOUT"), + ) + llm_temperature: float = Field( + default=0.2, + ge=0, + le=2, + validation_alias=AliasChoices("RISK_LLM_TEMPERATURE", "LLM_TEMPERATURE"), + ) + cors_allow_origins: Annotated[tuple[str, ...], NoDecode] = ( + "http://localhost:39158", + "http://127.0.0.1:39158", + ) + + model_config = SettingsConfigDict( + env_file=".env", + env_prefix="RISK_", + extra="ignore", + case_sensitive=False, + ) + + @field_validator("database_url") + @classmethod + def validate_database_url(cls, value: str) -> str: + if not value.startswith("postgresql+asyncpg://"): + raise ValueError("database_url must use the postgresql+asyncpg:// scheme") + return value + + @field_validator("llm_base_url") + @classmethod + def normalize_llm_base_url(cls, value: str) -> str: + return value.rstrip("/") + + @field_validator("cors_allow_origins", mode="before") + @classmethod + def split_cors_origins(cls, value: object) -> object: + if isinstance(value, str): + value = tuple(item.strip() for item in value.split(",") if item.strip()) + if isinstance(value, (list, tuple)) and "*" in value: + raise ValueError("cors_allow_origins must not contain '*' wildcard") + return value + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + return Settings() diff --git a/app/core/exceptions.py b/app/core/exceptions.py new file mode 100644 index 0000000..065dca9 --- /dev/null +++ b/app/core/exceptions.py @@ -0,0 +1,30 @@ +"""Application exceptions and response handler.""" + +from typing import Any + +from fastapi import Request +from fastapi.responses import JSONResponse + + +class AppException(Exception): + def __init__( + self, + message: str, + *, + code: int = 400, + status_code: int = 400, + details: Any = None, + ) -> None: + super().__init__(message) + self.message = message + self.code = code + self.status_code = status_code + self.details = details + + +async def app_exception_handler(_: Request, exc: AppException) -> JSONResponse: + return JSONResponse( + status_code=exc.status_code, + content={"code": exc.code, "message": exc.message, "data": exc.details}, + ) + diff --git a/app/core/logging.py b/app/core/logging.py new file mode 100644 index 0000000..ec2a5e1 --- /dev/null +++ b/app/core/logging.py @@ -0,0 +1,20 @@ +"""Shared logging setup.""" + +import logging + +LOGGER_NAME = "dieya_risk_prediction" + + +def configure_logging(level: str = "INFO") -> logging.Logger: + logger = logging.getLogger(LOGGER_NAME) + logger.setLevel(level.upper()) + logger.propagate = False + if not logger.handlers: + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")) + logger.addHandler(handler) + return logger + + +logger = configure_logging() + diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..a3b16fe --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,61 @@ +"""Password hashing and JWT helpers.""" + +import hashlib +import hmac +import secrets +from datetime import UTC, datetime, timedelta +from typing import Any + +import jwt + +from app.core.config import get_settings + +_PBKDF2_ALGORITHM = "sha256" +_PBKDF2_ITERATIONS = 310_000 + + +def hash_password(password: str) -> str: + """Hash a password using salted PBKDF2-HMAC.""" + + salt = secrets.token_bytes(16) + digest = hashlib.pbkdf2_hmac(_PBKDF2_ALGORITHM, password.encode(), salt, _PBKDF2_ITERATIONS) + return f"pbkdf2_sha256${_PBKDF2_ITERATIONS}${salt.hex()}${digest.hex()}" + + +def verify_password(password: str, encoded: str | None) -> bool: + """Verify a password hash without revealing whether the account exists.""" + + if not encoded: + return False + try: + scheme, iterations, salt_hex, digest_hex = encoded.split("$", 3) + if scheme != "pbkdf2_sha256": + return False + candidate = hashlib.pbkdf2_hmac( + _PBKDF2_ALGORITHM, + password.encode(), + bytes.fromhex(salt_hex), + int(iterations), + ) + return hmac.compare_digest(candidate.hex(), digest_hex) + except (TypeError, ValueError): + return False + + +def create_access_token(subject: int, *, role: str) -> str: + settings = get_settings() + expires_at = datetime.now(UTC) + timedelta(minutes=settings.jwt_expire_minutes) + payload: dict[str, Any] = {"sub": str(subject), "role": role, "exp": expires_at} + return jwt.encode(payload, settings.jwt_secret_key.get_secret_value(), algorithm=settings.jwt_algorithm) + + +def decode_access_token(token: str) -> dict[str, Any] | None: + settings = get_settings() + try: + return jwt.decode( + token, + settings.jwt_secret_key.get_secret_value(), + algorithms=[settings.jwt_algorithm], + ) + except jwt.PyJWTError: + return None diff --git a/app/database/__init__.py b/app/database/__init__.py new file mode 100644 index 0000000..331fd00 --- /dev/null +++ b/app/database/__init__.py @@ -0,0 +1,2 @@ +"""Database package.""" + diff --git a/app/database/base.py b/app/database/base.py new file mode 100644 index 0000000..3d5fcad --- /dev/null +++ b/app/database/base.py @@ -0,0 +1,39 @@ +"""SQLAlchemy declarative base and common mixins.""" + +from datetime import UTC, datetime +from typing import Annotated +from uuid import UUID, uuid4 + +from sqlalchemy import DateTime, func +from sqlalchemy.dialects.postgresql import UUID as PostgreSQLUUID +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +UUIDPrimaryKey = Annotated[ + UUID, + mapped_column(PostgreSQLUUID(as_uuid=True), primary_key=True, default=uuid4), +] + + +class Base(DeclarativeBase): + pass + + +class UUIDMixin: + id: Mapped[UUIDPrimaryKey] + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + server_default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + server_default=func.now(), + nullable=False, + ) + diff --git a/app/database/session.py b/app/database/session.py new file mode 100644 index 0000000..7ab1e73 --- /dev/null +++ b/app/database/session.py @@ -0,0 +1,28 @@ +"""Async SQLAlchemy engine, session factory, and request transaction boundary.""" + +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine + +from app.core.config import Settings, get_settings + + +def create_database_engine(settings: Settings | None = None) -> AsyncEngine: + resolved_settings = settings or get_settings() + return create_async_engine(resolved_settings.database_url, pool_pre_ping=True) + + +engine = create_database_engine() +SessionFactory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def get_db_session() -> AsyncGenerator[AsyncSession, None]: + async with SessionFactory() as session: + try: + yield session + except Exception: + await session.rollback() + raise + else: + await session.commit() + diff --git a/app/domain/__init__.py b/app/domain/__init__.py new file mode 100644 index 0000000..9c1226e --- /dev/null +++ b/app/domain/__init__.py @@ -0,0 +1,2 @@ +"""Domain package.""" + diff --git a/app/domain/risk_engine.py b/app/domain/risk_engine.py new file mode 100644 index 0000000..78299a0 --- /dev/null +++ b/app/domain/risk_engine.py @@ -0,0 +1,71 @@ +"""Replaceable baseline risk scoring engine.""" + +from dataclasses import dataclass +from enum import StrEnum + + +class RiskLevel(StrEnum): + LOW = "low" + MODERATELY_LOW = "moderately_low" + MEDIUM = "medium" + HIGH = "high" + VERY_HIGH = "very_high" + + +@dataclass(frozen=True, slots=True) +class IndicatorValue: + name: str + value: float + weight: float + + +@dataclass(frozen=True, slots=True) +class FactorContribution: + name: str + value: float + weight: float + contribution: float + + +@dataclass(frozen=True, slots=True) +class RiskScore: + score: float + level: RiskLevel + factors: tuple[FactorContribution, ...] + + +class BaselineRiskEngine: + """Weighted safety-score baseline following the PRD scoring direction. + + Input indicator values represent risk intensity (0 = no observed risk, + 100 = maximum risk). The public score is the inverse safety score, so a + higher result always means a safer assessment. + """ + + model_version = "baseline-safety-v2" + + def predict(self, indicators: list[IndicatorValue]) -> RiskScore: + total_weight = sum(item.weight for item in indicators) + factors = tuple( + FactorContribution( + name=item.name, + value=item.value, + weight=item.weight, + contribution=round((100 - item.value) * item.weight / total_weight, 2), + ) + for item in indicators + ) + score = round(sum(item.contribution for item in factors), 2) + return RiskScore(score=score, level=self._classify(score), factors=factors) + + @staticmethod + def _classify(score: float) -> RiskLevel: + if score >= 80: + return RiskLevel.LOW + if score >= 60: + return RiskLevel.MODERATELY_LOW + if score >= 40: + return RiskLevel.MEDIUM + if score >= 20: + return RiskLevel.HIGH + return RiskLevel.VERY_HIGH diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..6ffe676 --- /dev/null +++ b/app/main.py @@ -0,0 +1,34 @@ +"""FastAPI application factory.""" + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.router import api_router +from app.core.config import Settings, get_settings +from app.core.exceptions import AppException, app_exception_handler +from app.core.logging import configure_logging + + +def create_app(settings: Settings | None = None) -> FastAPI: + resolved_settings = settings or get_settings() + configure_logging(resolved_settings.log_level) + + application = FastAPI( + title=resolved_settings.app_name, + version=resolved_settings.app_version, + debug=resolved_settings.debug, + ) + application.add_middleware( + CORSMiddleware, + allow_origins=list(resolved_settings.cors_allow_origins), + allow_credentials=True, + allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"], + allow_headers=["Authorization", "Content-Type", "Accept"], + ) + application.add_exception_handler(AppException, app_exception_handler) + application.include_router(api_router) + return application + + +app = create_app() + diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..9488dce --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,29 @@ +"""ORM model exports.""" + +from app.models.entities import ( + AnomalyCase, + Conversation, + ConversationMessage, + OperationAudit, + ReviewRecord, + RiskAssessment, + RiskItem, + RolePermission, + StandardRule, + SupplierProfile, + UserAccount, +) + +__all__ = [ + "AnomalyCase", + "Conversation", + "ConversationMessage", + "OperationAudit", + "ReviewRecord", + "RiskAssessment", + "RiskItem", + "RolePermission", + "StandardRule", + "SupplierProfile", + "UserAccount", +] diff --git a/app/models/entities.py b/app/models/entities.py new file mode 100644 index 0000000..b2e6ce5 --- /dev/null +++ b/app/models/entities.py @@ -0,0 +1,349 @@ +"""PRD-defined core PostgreSQL models for the risk prediction platform.""" + +from datetime import date, datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import BigInteger, Boolean, Date, DateTime, ForeignKey, Index, Integer, Numeric, String, Text, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database.base import Base + + +class AnomalyCase(Base): + """Approved historical anomaly case used by the experience engine.""" + + __tablename__ = "anomaly_cases" + __table_args__ = ( + Index("ix_anomaly_cases_date", "date"), + Index("ix_anomaly_cases_product_category", "product_category"), + Index("ix_anomaly_cases_supplier", "supplier"), + Index("ix_anomaly_cases_factory", "factory"), + Index("ix_anomaly_cases_material_type", "material_type"), + Index("ix_anomaly_cases_defect_level", "defect_level"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + date: Mapped[date | None] = mapped_column(Date) + product_code: Mapped[str | None] = mapped_column(String(100)) + product_name: Mapped[str | None] = mapped_column(String(500)) + brand: Mapped[str | None] = mapped_column(String(100)) + specification: Mapped[str | None] = mapped_column(String(100)) + product_category: Mapped[str | None] = mapped_column(String(100)) + stage_new: Mapped[str | None] = mapped_column(String(100)) + stage_old: Mapped[str | None] = mapped_column(String(100)) + anomaly_type: Mapped[str | None] = mapped_column(String(200)) + material_type: Mapped[str | None] = mapped_column(String(200)) + supplier: Mapped[str | None] = mapped_column(String(300)) + factory: Mapped[str | None] = mapped_column(String(300)) + person: Mapped[str | None] = mapped_column(String(200)) + description: Mapped[str | None] = mapped_column(Text) + root_cause: Mapped[str | None] = mapped_column(Text) + solution: Mapped[str | None] = mapped_column(Text) + result: Mapped[str | None] = mapped_column(Text) + responsible: Mapped[str | None] = mapped_column(String(300)) + defect_level: Mapped[str | None] = mapped_column(String(10)) + is_resolved: Mapped[str | None] = mapped_column(Text) + source_file: Mapped[str | None] = mapped_column(String(300)) + source_type: Mapped[str | None] = mapped_column(String(30)) + remark: Mapped[str | None] = mapped_column(Text) + import_fingerprint: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + standard_rule_id: Mapped[int | None] = mapped_column( + BigInteger, + ForeignKey("standard_rules.id", ondelete="SET NULL"), + ) + # pgvector is not installed on the target PostgreSQL server. Store vectors as JSONB until it is available. + embedding: Mapped[list[float] | None] = mapped_column(JSONB) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + standard_rule: Mapped["StandardRule | None"] = relationship(back_populates="anomaly_cases") + review_records: Mapped[list["ReviewRecord"]] = relationship(back_populates="case") + + +class SupplierProfile(Base): + """Aggregated supplier risk profile.""" + + __tablename__ = "supplier_profiles" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + supplier_name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + total_anomalies: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False) + frequency_coef: Mapped[Decimal] = mapped_column(Numeric(4, 2), default=Decimal("1.00"), server_default="1.00") + tier: Mapped[str | None] = mapped_column(String(1)) + category_breakdown: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, server_default="{}", nullable=False) + product_cat_breakdown: Mapped[dict[str, Any]] = mapped_column( + JSONB, + default=dict, + server_default="{}", + nullable=False, + ) + stage_breakdown: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, server_default="{}", nullable=False) + monthly_trend: Mapped[list[dict[str, Any]]] = mapped_column( + JSONB, + default=list, + server_default="[]", + nullable=False, + ) + risk_score: Mapped[int | None] = mapped_column(Integer) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +class RiskAssessment(Base): + """A user-triggered risk assessment and its structured result.""" + + __tablename__ = "risk_assessments" + __table_args__ = ( + Index("ix_risk_assessments_user_id", "user_id"), + Index("ix_risk_assessments_created_at", "created_at"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + user_id: Mapped[str | None] = mapped_column(String(50)) + product_name: Mapped[str | None] = mapped_column(String(200)) + params: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, server_default="{}", nullable=False) + risk_score: Mapped[int | None] = mapped_column(Integer) + risk_level: Mapped[str | None] = mapped_column(String(10)) + dimension_scores: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, server_default="{}", nullable=False) + similar_cases: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, server_default="{}", nullable=False) + ai_suggestions: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + +class Conversation(Base): + """A persistent assistant conversation owned by one authenticated user.""" + + __tablename__ = "conversations" + __table_args__ = ( + Index("ix_conversations_user_updated", "user_id", "updated_at"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + BigInteger, + ForeignKey("user_accounts.id", ondelete="CASCADE"), + nullable=False, + ) + title: Mapped[str] = mapped_column(String(120), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + messages: Mapped[list["ConversationMessage"]] = relationship( + back_populates="conversation", + cascade="all, delete-orphan", + order_by="ConversationMessage.created_at", + ) + + +class ConversationMessage(Base): + """One user or assistant message, including optional structured results.""" + + __tablename__ = "conversation_messages" + __table_args__ = ( + Index("ix_conversation_messages_conversation_created", "conversation_id", "created_at"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + conversation_id: Mapped[int] = mapped_column( + BigInteger, + ForeignKey("conversations.id", ondelete="CASCADE"), + nullable=False, + ) + role: Mapped[str] = mapped_column(String(20), nullable=False) + message_type: Mapped[str] = mapped_column(String(20), default="text", server_default="text", nullable=False) + content: Mapped[str] = mapped_column(Text, nullable=False) + structured_data: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, server_default="{}", nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + conversation: Mapped[Conversation] = relationship(back_populates="messages") + + +class RiskItem(Base): + """Risk item submitted through form, voice, OCR, or import before approval.""" + + __tablename__ = "risk_items" + __table_args__ = ( + Index("ix_risk_items_status", "status"), + Index("ix_risk_items_submitted_by", "submitted_by"), + Index("ix_risk_items_submitted_at", "submitted_at"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + source_type: Mapped[str] = mapped_column(String(20), nullable=False) + source_file_id: Mapped[str | None] = mapped_column(String(50)) + product_name: Mapped[str | None] = mapped_column(String(200)) + product_category: Mapped[str | None] = mapped_column(String(50)) + supplier: Mapped[str | None] = mapped_column(String(100)) + factory: Mapped[str | None] = mapped_column(String(100)) + stage_new: Mapped[str | None] = mapped_column(String(50)) + description: Mapped[str | None] = mapped_column(Text) + defect_level: Mapped[str | None] = mapped_column(String(5)) + solution: Mapped[str | None] = mapped_column(Text) + photos: Mapped[list[str]] = mapped_column(JSONB, default=list, server_default="[]", nullable=False) + submitted_by: Mapped[str | None] = mapped_column(String(50)) + submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + status: Mapped[str] = mapped_column(String(20), default="待预审", server_default="待预审", nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + review_records: Mapped[list["ReviewRecord"]] = relationship( + back_populates="risk_item", + cascade="all, delete-orphan", + ) + + +class ReviewRecord(Base): + """Immutable review trail for a submitted risk item.""" + + __tablename__ = "review_records" + __table_args__ = ( + Index("ix_review_records_risk_item_id", "risk_item_id"), + Index("ix_review_records_reviewer", "reviewer"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + risk_item_id: Mapped[int] = mapped_column( + BigInteger, + ForeignKey("risk_items.id", ondelete="CASCADE"), + nullable=False, + ) + pre_review_result: Mapped[str | None] = mapped_column(String(20)) + pre_review_note: Mapped[str | None] = mapped_column(Text) + reviewer: Mapped[str | None] = mapped_column(String(50)) + review_action: Mapped[str | None] = mapped_column(String(20)) + review_note: Mapped[str | None] = mapped_column(Text) + reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + case_id: Mapped[int | None] = mapped_column( + BigInteger, + ForeignKey("anomaly_cases.id", ondelete="SET NULL"), + ) + + risk_item: Mapped[RiskItem] = relationship(back_populates="review_records") + case: Mapped[AnomalyCase | None] = relationship(back_populates="review_records") + + +class StandardRule(Base): + """Four-level judgment standard used by the rule engine.""" + + __tablename__ = "standard_rules" + __table_args__ = ( + Index("ix_standard_rules_categories", "category_lv1", "category_lv2", "category_lv3"), + Index("ix_standard_rules_status", "status"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + category_lv1: Mapped[str] = mapped_column(String(20), nullable=False) + category_lv2: Mapped[str | None] = mapped_column(String(60)) + category_lv3: Mapped[str | None] = mapped_column(String(60)) + risk_point: Mapped[str] = mapped_column(Text, nullable=False) + judge_standard: Mapped[str] = mapped_column(Text, nullable=False) + regulation: Mapped[str | None] = mapped_column(String(300)) + attachment: Mapped[str | None] = mapped_column(String(500)) + status: Mapped[str] = mapped_column(String(10), default="启用", server_default="启用", nullable=False) + import_fingerprint: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + anomaly_cases: Mapped[list[AnomalyCase]] = relationship(back_populates="standard_rule") + + +class UserAccount(Base): + """Enterprise user account and RBAC role assignment.""" + + __tablename__ = "user_accounts" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + emp_no: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) + email: Mapped[str | None] = mapped_column(String(128), unique=True) + name: Mapped[str] = mapped_column(String(50), nullable=False) + dept: Mapped[str | None] = mapped_column(String(50)) + position: Mapped[str | None] = mapped_column(String(50)) + role: Mapped[str] = mapped_column(String(20), nullable=False) + password_hash: Mapped[str | None] = mapped_column(String(255)) + status: Mapped[str] = mapped_column(String(10), default="启用", server_default="启用", nullable=False) + login_failed_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False) + locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + dingtalk_id: Mapped[str | None] = mapped_column(String(50), unique=True) + dingtalk_union_id: Mapped[str | None] = mapped_column(String(64)) + dingtalk_avatar: Mapped[str | None] = mapped_column(String(512)) + dingtalk_job_number: Mapped[str | None] = mapped_column(String(64)) + dingtalk_title: Mapped[str | None] = mapped_column(String(128)) + dingtalk_dept_id: Mapped[int | None] = mapped_column(BigInteger) + dingtalk_is_dept_manager: Mapped[bool] = mapped_column( + Boolean, + default=False, + server_default="false", + nullable=False, + ) + dingtalk_role_codes: Mapped[str | None] = mapped_column(String(512)) + last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + extra_perms: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, default=list, server_default="[]", nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +class RolePermission(Base): + """Function and data-level permissions assigned to a role.""" + + __tablename__ = "role_permissions" + __table_args__ = ( + Index("uq_role_permissions_role_func", "role", "func_key", unique=True), + Index("ix_role_permissions_role", "role"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + role: Mapped[str] = mapped_column(String(20), nullable=False) + func_key: Mapped[str] = mapped_column(String(50), nullable=False) + data_level: Mapped[str] = mapped_column(String(10), default="L1", server_default="L1", nullable=False) + can_view: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false", nullable=False) + can_operate: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false", nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +class OperationAudit(Base): + """Append-only audit record for authentication, permission, and review actions.""" + + __tablename__ = "operation_audits" + __table_args__ = ( + Index("ix_operation_audits_actor_created", "actor_user_id", "created_at"), + Index("ix_operation_audits_action_created", "action", "created_at"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + actor_user_id: Mapped[int | None] = mapped_column(BigInteger) + actor_emp_no: Mapped[str | None] = mapped_column(String(20)) + action: Mapped[str] = mapped_column(String(64), nullable=False) + resource_type: Mapped[str | None] = mapped_column(String(64)) + resource_id: Mapped[str | None] = mapped_column(String(128)) + result: Mapped[str] = mapped_column(String(20), default="success", server_default="success", nullable=False) + audit_metadata: Mapped[dict[str, Any]] = mapped_column( + "metadata", + JSONB, + default=dict, + server_default="{}", + nullable=False, + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) diff --git a/app/models/risk_prediction.py b/app/models/risk_prediction.py new file mode 100644 index 0000000..64d60f6 --- /dev/null +++ b/app/models/risk_prediction.py @@ -0,0 +1,8 @@ +"""Compatibility export for the PRD risk assessment model.""" + +from app.models.entities import RiskAssessment + +RiskPrediction = RiskAssessment + +__all__ = ["RiskAssessment", "RiskPrediction"] + diff --git a/app/repositories/__init__.py b/app/repositories/__init__.py new file mode 100644 index 0000000..c21b718 --- /dev/null +++ b/app/repositories/__init__.py @@ -0,0 +1,6 @@ +"""Repository exports.""" + +from app.repositories.risk_prediction import RiskPredictionRepository + +__all__ = ["RiskPredictionRepository"] + diff --git a/app/repositories/base.py b/app/repositories/base.py new file mode 100644 index 0000000..104e78a --- /dev/null +++ b/app/repositories/base.py @@ -0,0 +1,25 @@ +"""Generic async repository primitives.""" + +from typing import Generic, TypeVar +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.base import Base + +ModelT = TypeVar("ModelT", bound=Base) + + +class BaseRepository(Generic[ModelT]): + def __init__(self, session: AsyncSession, model: type[ModelT]) -> None: + self.session = session + self.model = model + + async def add(self, entity: ModelT) -> ModelT: + self.session.add(entity) + await self.session.flush() + return entity + + async def get(self, entity_id: UUID) -> ModelT | None: + return await self.session.get(self.model, entity_id) + diff --git a/app/repositories/conversations.py b/app/repositories/conversations.py new file mode 100644 index 0000000..0f8299e --- /dev/null +++ b/app/repositories/conversations.py @@ -0,0 +1,74 @@ +"""Persistence queries for user-owned assistant conversations.""" + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.entities import Conversation, ConversationMessage + + +class ConversationRepository: + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def list_for_user( + self, + user_id: int, + *, + limit: int = 50, + search: str | None = None, + ) -> list[Conversation]: + filters = [Conversation.user_id == user_id] + if search: + filters.append(Conversation.title.ilike(f"%{search}%")) + statement = ( + select(Conversation) + .where(*filters) + .options(selectinload(Conversation.messages)) + .order_by(Conversation.updated_at.desc(), Conversation.id.desc()) + .limit(limit) + ) + return list(await self.session.scalars(statement)) + + async def get_page_for_user( + self, + conversation_id: int, + user_id: int, + *, + limit: int = 20, + offset: int = 0, + ) -> tuple[Conversation, list[ConversationMessage], int] | None: + conversation = await self.session.scalar( + select(Conversation) + .where( + Conversation.id == conversation_id, + Conversation.user_id == user_id, + ) + .options(selectinload(Conversation.messages)) + ) + if conversation is None: + return None + total = await self.session.scalar( + select(func.count()).select_from(ConversationMessage).where( + ConversationMessage.conversation_id == conversation_id, + ) + ) + messages = list( + await self.session.scalars( + select(ConversationMessage) + .where(ConversationMessage.conversation_id == conversation_id) + .order_by(ConversationMessage.created_at.desc(), ConversationMessage.id.desc()) + .offset(offset) + .limit(limit) + ) + ) + messages.reverse() + return conversation, messages, int(total or 0) + + async def get_for_user(self, conversation_id: int, user_id: int) -> Conversation | None: + statement = ( + select(Conversation) + .where(Conversation.id == conversation_id, Conversation.user_id == user_id) + .options(selectinload(Conversation.messages)) + ) + return await self.session.scalar(statement) diff --git a/app/repositories/risk_prediction.py b/app/repositories/risk_prediction.py new file mode 100644 index 0000000..5d504fd --- /dev/null +++ b/app/repositories/risk_prediction.py @@ -0,0 +1,18 @@ +"""Risk assessment persistence operations.""" + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.entities import RiskAssessment +from app.repositories.base import BaseRepository + + +class RiskPredictionRepository(BaseRepository[RiskAssessment]): + def __init__(self, session: AsyncSession) -> None: + super().__init__(session, RiskAssessment) + + async def list_recent(self, *, limit: int = 20) -> list[RiskAssessment]: + statement = select(RiskAssessment).order_by(RiskAssessment.created_at.desc()).limit(limit) + result = await self.session.scalars(statement) + return list(result) + diff --git a/app/repositories/users.py b/app/repositories/users.py new file mode 100644 index 0000000..daf1f76 --- /dev/null +++ b/app/repositories/users.py @@ -0,0 +1,33 @@ +"""User and RBAC repository queries.""" + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.entities import RolePermission, UserAccount + + +class UserRepository: + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def get_by_id(self, user_id: int) -> UserAccount | None: + return await self.session.get(UserAccount, user_id) + + async def get_by_username(self, username: str) -> UserAccount | None: + statement = select(UserAccount).where( + (UserAccount.emp_no == username) | (UserAccount.email == username) + ) + return await self.session.scalar(statement) + + async def get_by_dingtalk_id(self, dingtalk_id: str) -> UserAccount | None: + statement = select(UserAccount).where(UserAccount.dingtalk_id == dingtalk_id) + return await self.session.scalar(statement) + + async def permissions_for_role(self, role: str, *, operate: bool | None = None) -> list[RolePermission]: + filters = [RolePermission.role == role] + if operate is True: + filters.append(RolePermission.can_operate.is_(True)) + elif operate is False: + filters.append(RolePermission.can_view.is_(True)) + statement = select(RolePermission).where(*filters) + return list(await self.session.scalars(statement)) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..39b4976 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,2 @@ +"""Pydantic schema package.""" + diff --git a/app/schemas/auth.py b/app/schemas/auth.py new file mode 100644 index 0000000..b68f7d8 --- /dev/null +++ b/app/schemas/auth.py @@ -0,0 +1,44 @@ +"""Authentication and current-user API contracts.""" + +from pydantic import Field + +from app.schemas.common import ApiSchema + + +class LoginRequest(ApiSchema): + username: str = Field(min_length=1, max_length=128) + password: str = Field(min_length=8, max_length=128) + + +class DingTalkLoginRequest(ApiSchema): + auth_code: str = Field(min_length=1, max_length=256) + + +class UserData(ApiSchema): + id: int + emp_no: str + email: str | None = None + name: str + dept: str | None = None + position: str | None = None + role: str + status: str + dingtalk_id: str | None = None + dingtalk_job_number: str | None = None + dingtalk_title: str | None = None + dingtalk_dept_id: int | None = None + dingtalk_is_dept_manager: bool = False + permissions: list[str] = Field(default_factory=list) + + +class TokenData(ApiSchema): + access_token: str + token_type: str = "bearer" + expires_in: int + user: UserData + + +class DingTalkConfigData(ApiSchema): + corp_id: str + app_key: str + agent_id: str diff --git a/app/schemas/common.py b/app/schemas/common.py new file mode 100644 index 0000000..59ec702 --- /dev/null +++ b/app/schemas/common.py @@ -0,0 +1,30 @@ +"""Shared API response schemas.""" + +from typing import Generic, TypeVar + +from pydantic import BaseModel, ConfigDict, Field + + +def to_camel(value: str) -> str: + head, *tail = value.split("_") + return head + "".join(part.capitalize() for part in tail) + + +class ApiSchema(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +DataT = TypeVar("DataT") + + +class ApiResponse(ApiSchema, Generic[DataT]): + code: int = 200 + message: str = "success" + data: DataT | None = Field(default=None) + + +class HealthData(ApiSchema): + status: str = "ok" + service: str + version: str + diff --git a/app/schemas/conversation.py b/app/schemas/conversation.py new file mode 100644 index 0000000..3c60ee4 --- /dev/null +++ b/app/schemas/conversation.py @@ -0,0 +1,55 @@ +"""Persistent assistant conversation contracts.""" + +from datetime import datetime +from typing import Literal + +from pydantic import Field + +from app.domain.risk_engine import RiskLevel +from app.schemas.common import ApiSchema +from app.schemas.risk_prediction import RiskAssessmentResult + + +class ConversationCreateRequest(ApiSchema): + title: str | None = Field(default=None, max_length=120) + + +class ConversationMessageCreateRequest(ApiSchema): + content: str = Field(min_length=1, max_length=2000) + message_type: Literal["text", "image", "file", "audio"] = "text" + + +class ConversationListQuery(ApiSchema): + limit: int = Field(default=50, ge=1, le=100) + + +class ConversationMessageData(ApiSchema): + id: int + role: str + message_type: Literal["text", "image", "file", "audio"] = "text" + content: str + risk_result: RiskAssessmentResult | None = None + response_card: dict | None = None + semantic: dict | None = None + created_at: datetime + + +class ConversationStreamEvent(ApiSchema): + type: Literal["start", "semantic", "delta", "done", "error"] + content: str | None = None + data: dict | None = None + + +class ConversationSummary(ApiSchema): + id: int + title: str + message_count: int + last_risk_level: RiskLevel | None = None + updated_at: datetime + + +class ConversationData(ConversationSummary): + messages: list[ConversationMessageData] + messages_total: int = 0 + messages_offset: int = 0 + has_more_messages: bool = False diff --git a/app/schemas/dashboard.py b/app/schemas/dashboard.py new file mode 100644 index 0000000..c07e6ed --- /dev/null +++ b/app/schemas/dashboard.py @@ -0,0 +1,74 @@ +"""Dashboard aggregation response contracts.""" + +from typing import Literal + +from pydantic import Field + +from app.schemas.common import ApiSchema + + +class DashboardMetric(ApiSchema): + value: int | float | None = None + label: str + change_percent: float | None = None + + +class DashboardStage(ApiSchema): + name: str + count: int + share: float + risk_level: str + + +class DashboardCategory(ApiSchema): + name: str + count: int + share: float + risk_level: str + top_material: str | None = None + + +class DashboardMatrixRow(ApiSchema): + name: str + party_type: str + categories: dict[str, int] + cooperation_total: int + anomaly_count: int + anomaly_rate: float + tier: str + + +class DashboardDefect(ApiSchema): + level: str + label: str + count: int + share: float + + +class DashboardSupplierRank(ApiSchema): + name: str + party_type: str + count: int + tier: str + top_problem: str | None = None + + +class DashboardTrendPoint(ApiSchema): + month: str + label: str + count: int + + +class DashboardData(ApiSchema): + year: Literal["all", "2024", "2025", "2026"] + total_cases: int + metrics: list[DashboardMetric] = Field(default_factory=list) + stages: list[DashboardStage] = Field(default_factory=list) + categories: list[DashboardCategory] = Field(default_factory=list) + matrix_categories: list[str] = Field(default_factory=list) + matrix: list[DashboardMatrixRow] = Field(default_factory=list) + defects: list[DashboardDefect] = Field(default_factory=list) + supplier_ranking: list[DashboardSupplierRank] = Field(default_factory=list) + trend: list[DashboardTrendPoint] = Field(default_factory=list) + insight: str + warnings: list[str] = Field(default_factory=list) diff --git a/app/schemas/risk_item.py b/app/schemas/risk_item.py new file mode 100644 index 0000000..851bd74 --- /dev/null +++ b/app/schemas/risk_item.py @@ -0,0 +1,69 @@ +"""Risk item submission and review contracts.""" + +from datetime import datetime + +from pydantic import Field + +from app.schemas.common import ApiSchema + + +class RiskItemCreate(ApiSchema): + source_type: str = Field(default="表单", min_length=1, max_length=20) + source_file_id: str | None = Field(default=None, max_length=50) + product_name: str = Field(min_length=1, max_length=200) + product_category: str | None = Field(default=None, max_length=100) + supplier: str | None = Field(default=None, max_length=300) + factory: str | None = Field(default=None, max_length=300) + stage_new: str | None = Field(default=None, max_length=100) + description: str = Field(min_length=5) + defect_level: str | None = Field(default=None, max_length=10) + solution: str | None = None + photos: list[str] = Field(default_factory=list, max_length=10) + + +class RiskItemUpdate(ApiSchema): + product_name: str | None = Field(default=None, min_length=1, max_length=200) + product_category: str | None = Field(default=None, max_length=100) + supplier: str | None = Field(default=None, max_length=300) + factory: str | None = Field(default=None, max_length=300) + stage_new: str | None = Field(default=None, max_length=100) + description: str | None = Field(default=None, min_length=5) + defect_level: str | None = Field(default=None, max_length=10) + solution: str | None = None + photos: list[str] | None = Field(default=None, max_length=10) + + +class RiskItemData(ApiSchema): + id: int + source_type: str + source_file_id: str | None + product_name: str | None + product_category: str | None + supplier: str | None + factory: str | None + stage_new: str | None + description: str | None + defect_level: str | None + solution: str | None + photos: list[str] + submitted_by: str | None + submitted_at: datetime | None + status: str + created_at: datetime + + +class ReviewActionRequest(ApiSchema): + action: str = Field(pattern="^(通过|驳回)$") + note: str = Field(min_length=2, max_length=2000) + + +class ReviewRecordData(ApiSchema): + id: int + risk_item_id: int + pre_review_result: str | None + pre_review_note: str | None + reviewer: str | None + review_action: str | None + review_note: str | None + reviewed_at: datetime | None + case_id: int | None diff --git a/app/schemas/risk_prediction.py b/app/schemas/risk_prediction.py new file mode 100644 index 0000000..528cc72 --- /dev/null +++ b/app/schemas/risk_prediction.py @@ -0,0 +1,91 @@ +"""Risk prediction request and response contracts.""" + +from pydantic import Field + +from app.domain.risk_engine import RiskLevel +from app.schemas.common import ApiSchema + + +class RiskIndicator(ApiSchema): + name: str = Field(min_length=1, max_length=64) + value: float = Field(ge=0, le=100) + weight: float = Field(default=1, gt=0, le=100) + + +class RiskAssessmentRequest(ApiSchema): + subject_id: str = Field(min_length=1, max_length=128) + indicators: list[RiskIndicator] = Field(min_length=1, max_length=50) + + +class RiskFactorResult(ApiSchema): + name: str + value: float + weight: float + contribution: float + + +class CategoryWarning(ApiSchema): + category: str + anomaly_count: int + risk_level: str + similar_category: str | None = None + similar_count: int | None = None + suggestion: str | None = None + + +class SupplierFrequency(ApiSchema): + supplier_name: str | None = None + cooperation_count: int | None = None + anomaly_count: int | None = None + alpha: float = 1.0 + tier: str | None = None + suggestion: str | None = None + + +class StandardJudgment(ApiSchema): + category: str + risk_point: str + rule: str + source: str | None = None + status: str = "待检测" + + +class RiskDimensionScore(ApiSchema): + dimension: str + score: float + weight: float + + +class SimilarCase(ApiSchema): + date: str | None = None + product: str | None = None + problem: str | None = None + solution: str | None = None + + +class RiskAssessmentResult(ApiSchema): + subject_id: str + score: float + level: RiskLevel + model_version: str + factors: list[RiskFactorResult] + risk_badge: str | None = None + alert_text: str | None = None + category_warning: CategoryWarning | None = None + supplier_freq: SupplierFrequency | None = None + standard_judgment: list[StandardJudgment] = Field(default_factory=list) + dimension_scores: list[RiskDimensionScore] = Field(default_factory=list) + similar_cases: list[SimilarCase] = Field(default_factory=list) + ai_suggestions: list[str] = Field(default_factory=list) + + +class ModelInfo(ApiSchema): + model_version: str + score_range: str = "0-100" + levels: tuple[RiskLevel, ...] = ( + RiskLevel.LOW, + RiskLevel.MODERATELY_LOW, + RiskLevel.MEDIUM, + RiskLevel.HIGH, + RiskLevel.VERY_HIGH, + ) diff --git a/app/schemas/standards.py b/app/schemas/standards.py new file mode 100644 index 0000000..4deeb22 --- /dev/null +++ b/app/schemas/standards.py @@ -0,0 +1,59 @@ +"""Judgment standard library API contracts.""" + +from datetime import datetime + +from pydantic import Field + +from app.schemas.common import ApiSchema + + +class StandardCategoryCount(ApiSchema): + category: str + count: int + + +class StandardRuleSummary(ApiSchema): + id: int + category_lv1: str + category_lv2: str | None = None + category_lv3: str | None = None + risk_point: str + judge_standard: str + regulation: str | None = None + status: str + updated_at: datetime + + +class StandardRuleDetail(StandardRuleSummary): + attachment: str | None = None + + +class StandardRuleListData(ApiSchema): + items: list[StandardRuleSummary] = Field(default_factory=list) + total: int + page: int + page_size: int + total_pages: int + category_counts: list[StandardCategoryCount] = Field(default_factory=list) + + +class StandardRuleCreate(ApiSchema): + category_lv1: str = Field(min_length=1, max_length=20) + category_lv2: str | None = Field(default=None, max_length=60) + category_lv3: str | None = Field(default=None, max_length=60) + risk_point: str = Field(min_length=1) + judge_standard: str = Field(min_length=1) + regulation: str | None = Field(default=None, max_length=300) + attachment: str | None = Field(default=None, max_length=500) + status: str = Field(default="启用", pattern="^(启用|停用)$") + + +class StandardRuleUpdate(ApiSchema): + category_lv1: str | None = Field(default=None, min_length=1, max_length=20) + category_lv2: str | None = Field(default=None, max_length=60) + category_lv3: str | None = Field(default=None, max_length=60) + risk_point: str | None = Field(default=None, min_length=1) + judge_standard: str | None = Field(default=None, min_length=1) + regulation: str | None = Field(default=None, max_length=300) + attachment: str | None = Field(default=None, max_length=500) + status: str | None = Field(default=None, pattern="^(启用|停用)$") diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..300c0fc --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,2 @@ +"""Service package.""" + diff --git a/app/services/auth.py b/app/services/auth.py new file mode 100644 index 0000000..dd90725 --- /dev/null +++ b/app/services/auth.py @@ -0,0 +1,102 @@ +"""Local account authentication and RBAC mapping.""" + +from datetime import UTC, datetime, timedelta + +from app.core.config import get_settings +from app.core.exceptions import AppException +from app.core.security import create_access_token, verify_password +from app.models.entities import OperationAudit, UserAccount +from app.repositories.users import UserRepository +from app.schemas.auth import TokenData, UserData + +MAX_LOGIN_FAILURES = 5 +LOCK_MINUTES = 30 + + +class AuthService: + def __init__(self, session) -> None: # noqa: ANN001 + self.repo = UserRepository(session) + self.session = session + + async def login(self, username: str, password: str) -> TokenData: + user = await self.repo.get_by_username(username) + now = datetime.now(UTC) + if user is None or user.status != "启用": + self.session.add( + OperationAudit( + actor_emp_no=username[:20], + action="auth.login", + result="failure", + audit_metadata={"reason": "invalid_credentials"}, + ) + ) + await self.session.commit() + raise AppException("工号/企业邮箱或密码错误", code=401, status_code=401) + if user.locked_until and user.locked_until > now: + self.session.add( + OperationAudit( + actor_user_id=user.id, + actor_emp_no=user.emp_no, + action="auth.login", + result="failure", + audit_metadata={"reason": "account_locked"}, + ) + ) + await self.session.commit() + raise AppException("账号已锁定,请稍后重试", code=423, status_code=423) + if not verify_password(password, user.password_hash): + user.login_failed_count += 1 + if user.login_failed_count >= MAX_LOGIN_FAILURES: + user.locked_until = now + timedelta(minutes=LOCK_MINUTES) + user.login_failed_count = 0 + self.session.add( + OperationAudit( + actor_user_id=user.id, + actor_emp_no=user.emp_no, + action="auth.login", + result="failure", + audit_metadata={"reason": "invalid_credentials"}, + ) + ) + await self.session.flush() + await self.session.commit() + raise AppException("工号/企业邮箱或密码错误", code=401, status_code=401) + user.login_failed_count = 0 + user.locked_until = None + user.last_login_at = now + self.session.add(OperationAudit(actor_user_id=user.id, actor_emp_no=user.emp_no, action="auth.login")) + await self.session.flush() + return await self._token_data(user) + + async def _token_data(self, user: UserAccount) -> TokenData: + permissions = await self.repo.permissions_for_role(user.role) + permission_keys = sorted({permission.func_key for permission in permissions}) + settings = get_settings() + return TokenData( + access_token=create_access_token(user.id, role=user.role), + expires_in=settings.jwt_expire_minutes * 60, + user=self.to_user_data(user, permission_keys), + ) + + @staticmethod + def to_user_data(user: UserAccount, permissions: list[str] | None = None) -> UserData: + return UserData( + id=user.id, + emp_no=user.emp_no, + email=user.email, + name=user.name, + dept=user.dept, + position=user.position, + role=user.role, + status=user.status, + dingtalk_id=user.dingtalk_id, + dingtalk_job_number=user.dingtalk_job_number, + dingtalk_title=user.dingtalk_title, + dingtalk_dept_id=user.dingtalk_dept_id, + dingtalk_is_dept_manager=user.dingtalk_is_dept_manager, + permissions=permissions or [], + ) + + async def me(self, user: UserAccount) -> UserData: + permissions = sorted({permission.func_key for permission in await self.repo.permissions_for_role(user.role)}) + return self.to_user_data(user, permissions) diff --git a/app/services/conversations.py b/app/services/conversations.py new file mode 100644 index 0000000..454bc75 --- /dev/null +++ b/app/services/conversations.py @@ -0,0 +1,1008 @@ +"""Conversation use cases and the initial risk-assistant response adapter.""" + +import json +import re +from collections.abc import AsyncGenerator, Sequence +from datetime import UTC, datetime +from typing import Any, Literal + +from sqlalchemy import func, or_, select + +from app.core.exceptions import AppException +from app.domain.risk_engine import BaselineRiskEngine, IndicatorValue, RiskLevel +from app.models.entities import AnomalyCase, Conversation, ConversationMessage, StandardRule, UserAccount +from app.repositories.conversations import ConversationRepository +from app.schemas.conversation import ( + ConversationData, + ConversationMessageData, + ConversationSummary, +) +from app.schemas.risk_prediction import ( + CategoryWarning, + RiskAssessmentResult, + RiskDimensionScore, + RiskFactorResult, + StandardJudgment, + SupplierFrequency, +) +from app.services.llm_client import LLMClient + + +class ConversationService: + _engine = BaselineRiskEngine() + _risk_intents = { + "assessment", + "supplier", + "material", + "cases", + "standard", + "compliance", + "solution", + } + _risk_keywords = ( + "风险", + "评估", + "预估", + "判定", + "标准", + "案例", + "供应商", + "物料", + "预防方案", + "合规", + ) + _product_assessment_keywords = ( + "新品", + "新产品", + "产品开发", + "产品立项", + "规格", + "容量", + "包装", + "材质", + "供应商", + "加工厂", + "配方", + "原料", + ) + + def __init__(self, session) -> None: # noqa: ANN001 + self.session = session + self.repository = ConversationRepository(session) + self.llm = LLMClient() + + async def list( + self, + user: UserAccount, + *, + limit: int = 50, + search: str | None = None, + ) -> list[ConversationSummary]: + conversations = await self.repository.list_for_user(user.id, limit=limit, search=search) + return [self._summary(item) for item in conversations] + + async def get( + self, + conversation_id: int, + user: UserAccount, + *, + limit: int = 20, + offset: int = 0, + ) -> ConversationData: + page = await self.repository.get_page_for_user(conversation_id, user.id, limit=limit, offset=offset) + if page is None: + raise AppException("对话不存在或无权访问", code=404, status_code=404) + conversation, messages, total = page + live_results = await self._refresh_risk_results(messages) + return self._data( + conversation, + messages=messages, + total=total, + offset=offset, + risk_results=live_results, + ) + + async def create(self, user: UserAccount, title: str | None = None) -> ConversationData: + conversation = Conversation( + user_id=user.id, + title=(title or "新建对话").strip()[:120] or "新建对话", + ) + self.session.add(conversation) + await self.session.flush() + persisted = await self.repository.get_for_user(conversation.id, user.id) + if persisted is None: # pragma: no cover - the row was just inserted + raise AppException("对话创建失败", code=500, status_code=500) + return self._data(persisted, messages=[], total=0, offset=0) + + async def send( + self, + conversation_id: int, + content: str, + user: UserAccount, + *, + message_type: Literal["text", "image", "file", "audio"] = "text", + ) -> ConversationData: + conversation = await self.repository.get_for_user(conversation_id, user.id) + if conversation is None: + raise AppException("对话不存在或无权访问", code=404, status_code=404) + + text = content.strip() + if not text: + raise AppException("对话内容不能为空", code=422, status_code=422) + + user_message = ConversationMessage( + conversation_id=conversation.id, + role="user", + message_type=message_type, + content=text, + ) + risk_result = None + if self._is_explicit_risk_request(text): + risk_result = await self._enrich_risk_result(text, {}, self._predict(text)) + assistant_message = ConversationMessage( + conversation_id=conversation.id, + role="assistant", + content="已根据当前描述完成风险预估,下面是本轮分析结果。你可以继续补充产品、供应商或标准信息。", + structured_data=( + {"riskResult": risk_result.model_dump(mode="json", by_alias=False)} + if risk_result + else {} + ), + ) + conversation.messages.extend([user_message, assistant_message]) + conversation.title = self._title_for(conversation, text) + conversation.updated_at = datetime.now(UTC) + await self.session.flush() + persisted = await self.repository.get_for_user(conversation.id, user.id) + if persisted is None: # pragma: no cover - the row already existed + raise AppException("对话读取失败", code=500, status_code=500) + page = await self.repository.get_page_for_user(conversation.id, user.id, limit=20, offset=0) + if page is None: # pragma: no cover - the row already existed + raise AppException("对话读取失败", code=500, status_code=500) + persisted, messages, total = page + live_results = await self._refresh_risk_results(messages) + return self._data( + persisted, + messages=messages, + total=total, + offset=0, + risk_results=live_results, + ) + + async def stream( + self, + conversation_id: int, + content: str, + user: UserAccount, + *, + message_type: Literal["text", "image", "file", "audio"] = "text", + ) -> AsyncGenerator[dict[str, Any], None]: + """Persist a user turn, then stream a real LLM response over SSE.""" + + conversation = await self.repository.get_for_user(conversation_id, user.id) + if conversation is None: + raise AppException("对话不存在或无权访问", code=404, status_code=404) + text = content.strip() + if not text: + raise AppException("对话内容不能为空", code=422, status_code=422) + + user_message = ConversationMessage( + conversation_id=conversation.id, + role="user", + message_type=message_type, + content=text, + ) + conversation.messages.append(user_message) + conversation.title = self._title_for(conversation, text) + conversation.updated_at = datetime.now(UTC) + await self.session.flush() + await self.session.commit() + + yield {"type": "start", "data": {"conversationId": conversation.id}} + try: + semantics = await self.llm.extract_semantics( + text, + history=self._history_messages(conversation, current_text=text), + ) + except Exception as exc: # noqa: BLE001 - report semantic failures through SSE + yield {"type": "error", "message": str(exc)} + return + semantics = self._normalize_semantics(text, semantics) + yield {"type": "semantic", "data": semantics} + + context = self._llm_messages(conversation, text, semantics) + chunks: list[str] = [] + try: + async for chunk in self.llm.stream_reply(context): + chunks.append(chunk) + yield {"type": "delta", "content": chunk} + except Exception as exc: # noqa: BLE001 - SSE must report model failures + await self.session.rollback() + yield {"type": "error", "message": str(exc)} + return + + reply = "".join(chunks).strip() + if not reply: + yield {"type": "error", "message": "LLM 未返回有效内容"} + return + + show_risk = self._is_risk_request(text, semantics) + response_type = self._response_type(text, semantics) + risk_result = ( + await self._enrich_risk_result(text, semantics, self._predict(text)) + if show_risk and response_type == "risk_assessment" + else None + ) + response_card = ( + await self._build_response_card(response_type, text, semantics) + if response_type != "general" + else None + ) + assistant_message = ConversationMessage( + conversation_id=conversation.id, + role="assistant", + message_type="text", + content=reply, + structured_data={ + "semantic": semantics, + **( + {"riskResult": risk_result.model_dump(mode="json", by_alias=False)} + if risk_result + else {} + ), + **({"responseCard": response_card} if response_card else {}), + }, + ) + self.session.add(assistant_message) + await self.session.flush() + await self.session.commit() + persisted = await self.repository.get_for_user(conversation.id, user.id) + if persisted is None: # pragma: no cover - the row already existed + yield {"type": "error", "message": "对话读取失败"} + return + page = await self.repository.get_page_for_user(conversation.id, user.id, limit=20, offset=0) + if page is None: # pragma: no cover - the row already existed + yield {"type": "error", "message": "对话读取失败"} + return + persisted, messages, total = page + live_results = await self._refresh_risk_results(messages) + result = self._data( + persisted, + messages=messages, + total=total, + offset=0, + risk_results=live_results, + ) + yield {"type": "done", "data": result.model_dump(mode="json", by_alias=True)} + + @staticmethod + def _llm_messages( + conversation: Conversation, + current_text: str, + semantics: dict[str, Any], + ) -> Sequence[dict[str, str]]: + messages: list[dict[str, str]] = [ + { + "role": "system", + "content": ( + "你是蝶雅产品风险预估助手。请基于用户当前问题和历史上下文回答," + "持续理解产品品类、规格、包材、供应商、加工厂和开发阶段。" + "回答要具体、简洁、可执行;不要声称已经完成 RAG、OCR 或语音识别。" + ), + } + ] + messages.append( + { + "role": "system", + "content": f"本轮语义理解 JSON:{json.dumps(semantics, ensure_ascii=False)}", + } + ) + history = conversation.messages[-20:] + for message in history: + if message.role in {"user", "assistant"}: + messages.append({"role": message.role, "content": message.content}) + if not history or history[-1].content != current_text: + messages.append({"role": "user", "content": current_text}) + return messages + + @classmethod + def _response_type(cls, text: str, semantics: dict[str, Any]) -> str: + """Choose the prototype card from the user's actual question.""" + + intent = str(semantics.get("intent") or "").strip().lower() + if intent == "supplier" or ( + any(word in text for word in ("供应商", "加工厂", "工厂")) + and any(word in text for word in ("怎么样", "风险", "画像", "异常", "合作")) + and not any(word in text for word in ("新品", "新产品", "评估", "预估")) + ): + return "supplier" + if intent == "material" or "物料" in text or "类物料" in text: + return "material" + if intent == "standard" or any(word in text for word in ("检测报告", "检测值", "判定", "pH", "PH")): + return "standard" + if intent == "compliance" or any(word in text for word in ("合规", "宣称", "备案", "法规")): + return "compliance" + if intent == "solution" or ( + any(word in text for word in ("次氯酸", "消毒喷雾")) + and not any(word in text for word in ("评估", "风险评估", "风险预估")) + ): + return "solution" + if intent == "assessment" or cls._is_explicit_risk_request(text): + return "risk_assessment" + return "general" + + async def _build_response_card( + self, + response_type: str, + text: str, + semantics: dict[str, Any], + ) -> dict[str, Any] | None: + builders = { + "supplier": self._supplier_card, + "material": self._material_card, + "standard": self._standard_card, + "compliance": self._compliance_card, + "solution": self._solution_card, + } + builder = builders.get(response_type) + return await builder(text, semantics) if builder else None + + async def _supplier_card(self, text: str, semantics: dict[str, Any]) -> dict[str, Any]: + name = self._semantic_text(semantics, "supplier") or self._semantic_text(semantics, "factory") + if not name: + match = re.search(r"(?:供应商|加工厂|工厂)\s*(?:是|用|为)?\s*([^,。!??]+)", text) + name = match.group(1).strip() if match else "未指定" + is_factory = any(word in text for word in ("加工厂", "工厂", "生产厂")) and "供应商" not in text + column = AnomalyCase.factory if is_factory else AnomalyCase.supplier + anomaly_count = await self._count_anomalies(column, name) + problems = await self._top_anomaly_types(column, name) + party_type = "加工厂" if is_factory else "供应商" + return { + "type": "supplier", + "title": "供应商深度画像", + "name": name, + "partyType": party_type, + "anomalyCount": anomaly_count, + "cooperationCount": None, + "tier": self._supplier_tier(anomaly_count), + "frequencyCoefficient": round(min(1.5, 1 + anomaly_count / 100 * 0.3), 2) if anomaly_count else 1.0, + "topProblems": problems, + "suggestions": ( + ["加强巡检并准备备选供应商", "重点复核高频异常对应的来料和过程检验项目"] + if anomaly_count + else ["暂无历史合作数据,建议首单全检并安排驻厂巡检"] + ), + "note": "合作次数需要采购合作台账接入后统计,当前仅展示异常案例库中的真实记录。", + } + + async def _material_card(self, text: str, semantics: dict[str, Any]) -> dict[str, Any]: + material = self._first_match(text, ("软管", "泵头", "瓶体", "瓶子", "彩盒", "铝瓶", "喷头", "PET")) + material = material or self._semantic_text(semantics, "packaging") or "未指定物料" + query_term = "软管" if "软管" in material else material + count = await self._count_anomalies(AnomalyCase.material_type, query_term) + problems = await self._top_anomaly_types(AnomalyCase.material_type, query_term) + standards = await self._standards_for("包材", text) + return { + "type": "material", + "title": "物料风险分析", + "material": material, + "anomalyCount": count, + "frequentProblems": problems, + "standards": [self._standard_dict(item) for item in standards[:4]], + "suggestions": [ + "打样阶段优先验证外观、密封性和内容物相容性", + "将高频异常纳入来料检验和过程巡检清单", + ], + } + + async def _standard_card(self, text: str, semantics: dict[str, Any]) -> dict[str, Any]: + category = self._semantic_text(semantics, "category") or self._first_match( + text, ("洗发水", "沐浴露", "面霜", "乳液", "脱毛膏", "清洁剂") + ) or "未指定品类" + standards = await self._standards_for(category, text) + values = re.findall(r"(?:pH|PH)\s*(?:值)?\s*[::]?\s*([0-9]+(?:\.[0-9]+)?)", text) + return { + "type": "standard", + "title": "标准判定", + "category": category, + "detectedValues": [{"name": "pH", "value": value} for value in values], + "matchedCount": len(standards), + "standards": [self._standard_dict(item) for item in standards], + "note": "检测结果仅完成规则库匹配,最终结论需由品控依据检测报告确认。", + } + + async def _compliance_card(self, text: str, semantics: dict[str, Any]) -> dict[str, Any]: + terms = ["法规", "标签", "宣称", "备案", "功效", "祛痘", "禁用", "限用"] + clauses = [ + or_( + StandardRule.category_lv1.ilike(f"%{term}%"), + StandardRule.category_lv2.ilike(f"%{term}%"), + StandardRule.category_lv3.ilike(f"%{term}%"), + StandardRule.risk_point.ilike(f"%{term}%"), + StandardRule.judge_standard.ilike(f"%{term}%"), + ) + for term in terms + ] + rows = list( + await self.session.scalars( + select(StandardRule).where(or_(*clauses)).order_by(StandardRule.id).limit(6) + ) + ) + return { + "type": "compliance", + "title": "法规合规核验", + "question": text, + "matchedCount": len(rows), + "standards": [ + { + "category": row.category_lv1, + "riskPoint": row.risk_point, + "rule": row.judge_standard, + "source": row.regulation or "标准文件.xlsx", + "status": "待确认", + } + for row in rows + ], + "suggestions": [ + "核对产品宣称与注册备案/标签允许范围", + "对功效宣称保留评价依据和合规证明材料", + ], + } + + async def _solution_card(self, text: str, semantics: dict[str, Any]) -> dict[str, Any]: + category = self._semantic_text(semantics, "category") or self._first_match( + text, ("次氯酸消毒喷雾", "消毒喷雾", "清洁剂", "脱毛膏") + ) or "当前新品" + direct_count = await self._count_category_anomalies(category) + standards = await self._standards_for(category, text) + risks: list[dict[str, str]] = [] + if "次氯酸" in text or "铝" in text: + risks.append( + { + "level": "高风险", + "title": "化学兼容性风险", + "detail": "强氧化性内容物与金属或内壁材料的相容性需要重点验证。", + } + ) + if "喷头" in text or "软管" in text: + risks.append( + { + "level": "中风险", + "title": "密封性风险", + "detail": "包装组合需验证密封、开启和长期储存稳定性。", + } + ) + if self._semantic_text(semantics, "supplier") or self._semantic_text(semantics, "factory"): + risks.append( + { + "level": "中风险", + "title": "供应链风险", + "detail": "供应商或加工厂的历史表现需要结合异常案例和现场审核确认。", + } + ) + return { + "type": "solution", + "title": "AI 案例推理生成", + "confidence": "中", + "analysisBase": ( + f"目标品类「{category}」当前命中历史异常 {direct_count} 条;" + "方案结合已导入异常案例和标准规则生成。" + if direct_count + else f"目标品类「{category}」暂无直接历史案例,当前按标准规则库进行兜底分析。" + ), + "standards": [self._standard_dict(item) for item in standards[:4]], + "risks": risks, + "suggestions": [ + "小样阶段完成内容物与包装材料相容性测试", + "增加倒置密封测试和加速老化测试", + "首单安排来料全检,并根据结果决定是否驻厂巡检", + "实际执行前由品控确认并安排小批量试产验证", + ], + "warning": "以上方案由 AI 推理生成,实际执行前请咨询品控部门确认。", + } + + async def _top_anomaly_types(self, column, value: str | None) -> Sequence[dict[str, Any]]: # noqa: ANN001 + if not value: + return [] + rows = await self.session.execute( + select(AnomalyCase.anomaly_type, func.count()) + .where(column.ilike(f"%{value}%"), AnomalyCase.anomaly_type.is_not(None)) + .group_by(AnomalyCase.anomaly_type) + .order_by(func.count().desc()) + .limit(5) + ) + return [{"name": row[0], "count": int(row[1])} for row in rows] + + @staticmethod + def _standard_dict(item: StandardJudgment) -> dict[str, Any]: + return { + "category": item.category, + "riskPoint": item.risk_point, + "rule": item.rule, + "source": item.source, + "status": item.status, + } + + @staticmethod + def _history_messages( + conversation: Conversation, + *, + current_text: str | None = None, + ) -> Sequence[dict[str, str]]: + history = [ + message + for message in conversation.messages[-10:] + if message.role in {"user", "assistant"} + ] + if current_text and history and history[-1].role == "user" and history[-1].content == current_text: + history.pop() + return [ + {"role": message.role, "content": message.content} + for message in history + ] + + def _predict(self, text: str) -> RiskAssessmentResult: + length = len(text) + score = self._engine.predict( + [ + IndicatorValue("supplierPerformance", min(90, 38 + (length % 35)), 25), + IndicatorValue("materialCompatibility", 68 if "软管" in text or "铝" in text else 42, 20), + IndicatorValue("categoryHistory", 58 if "新品" in text else 36, 15), + IndicatorValue( + "productionControl", + 54 if "加工厂" in text or "供应商" in text else 32, + 15, + ), + IndicatorValue("standardCompliance", 24 if "标准" in text or "检测" in text else 46, 10), + IndicatorValue( + "stageControl", + 54 if "量产" in text or "上市" in text else 42, + 10, + ), + IndicatorValue( + "defectSeverity", + 78 if "A级" in text or "致命" in text else 52 if "B级" in text else 28, + 5, + ), + ] + ) + return RiskAssessmentResult( + subject_id=text[:48] or "新风险评估", + score=score.score, + level=score.level, + model_version=self._engine.model_version, + factors=[ + RiskFactorResult( + name=factor.name, + value=factor.value, + weight=factor.weight, + contribution=factor.contribution, + ) + for factor in score.factors + ], + ) + + async def _enrich_risk_result( + self, + text: str, + semantics: dict[str, Any], + result: RiskAssessmentResult, + ) -> RiskAssessmentResult: + """Build the structured fields required by the prototype risk card.""" + + category = self._semantic_text(semantics, "category") or self._first_match( + text, + ("洗发水", "沐浴露", "面霜", "乳液", "精华", "面膜", "脱毛膏", "清洁剂", "牙膏"), + ) or "未归类" + supplier = self._semantic_text(semantics, "supplier") + factory = self._semantic_text(semantics, "factory") + # The model can occasionally put a named factory into `supplier`. + # An explicit factory marker in the user's text is authoritative. + if any(marker in text for marker in ("加工厂", "工厂", "生产厂")) and "供应商" not in text: + factory = factory or supplier + supplier = None + supplier_or_factory = supplier or factory + + category_count = await self._count_category_anomalies(category) + supplier_count = await self._count_anomalies(AnomalyCase.supplier, supplier_or_factory) + factory_count = await self._count_anomalies(AnomalyCase.factory, factory) + if factory and not supplier: + anomaly_count = factory_count + elif supplier and not factory: + anomaly_count = supplier_count + else: + anomaly_count = supplier_count or factory_count + standard_rows = await self._standards_for(category, text) + + alpha = round(min(1.5, 1 + (anomaly_count / 100) * 0.3), 2) if anomaly_count else 1.0 + tier = self._supplier_tier(anomaly_count) + level_text = self._risk_level_text(result.level) + alert_text = self._alert_text( + category, + category_count, + supplier_or_factory, + anomaly_count, + text, + ) + dimensions = [ + RiskDimensionScore( + dimension=self._dimension_name(factor.name), + score=round(max(0, 100 - factor.value), 2), + weight=factor.weight, + ) + for factor in result.factors + ] + suggestions = self._suggestions(text, supplier_or_factory) + + return result.model_copy( + update={ + "risk_badge": self._risk_badge(result.level), + "alert_text": alert_text, + "category_warning": CategoryWarning( + category=category, + anomaly_count=category_count, + risk_level="案例较少" if category_count < 10 else level_text, + suggestion=( + "案例不足,建议结合相似品类和标准库进行预判" + if category_count < 10 + else "建议结合当前品类高频异常制定验证计划" + ), + ), + "supplier_freq": SupplierFrequency( + supplier_name=supplier_or_factory, + cooperation_count=None, + anomaly_count=anomaly_count or None, + alpha=alpha, + tier=tier, + suggestion=( + "无历史数据,建议首单全检并安排驻厂巡检" + if not anomaly_count + else "建议加强巡检并准备备选供应商" + ), + ), + "standard_judgment": standard_rows, + "dimension_scores": dimensions, + "similar_cases": [], + "ai_suggestions": suggestions, + } + ) + + async def _count_anomalies(self, column, value: str | None) -> int: # noqa: ANN001 + if not value or value == "未归类": + return 0 + count = await self.session.scalar( + select(func.count()).select_from(AnomalyCase).where(column.ilike(f"%{value}%")) + ) + return int(count or 0) + + async def _count_category_anomalies(self, category: str) -> int: + """The imported workbook has sparse product_category values. + + Product names are therefore included as a transparent fallback so the + category warning reflects the 2,448 imported cases instead of returning + zero merely because one optional column is empty. + """ + + if not category or category == "未归类": + return 0 + count = await self.session.scalar( + select(func.count()) + .select_from(AnomalyCase) + .where( + or_( + AnomalyCase.product_category.ilike(f"%{category}%"), + AnomalyCase.product_name.ilike(f"%{category}%"), + ) + ) + ) + return int(count or 0) + + async def _standards_for(self, category: str, text: str) -> Sequence[StandardJudgment]: + terms = [term for term in [category] if term and term != "未归类" and term != "未指定品类"] + terms.extend( + term + for term in ( + self._first_match(text, ("软管", "PET", "铝瓶", "泵头", "彩盒", "瓶子", "喷头")), + self._first_match(text, ("pH", "PH", "密封", "标签", "宣称", "兼容性", "消毒")), + ) + if term + ) + if not terms: + terms = ["标准"] + clauses = [ + or_( + StandardRule.category_lv1.ilike(f"%{term}%"), + StandardRule.category_lv2.ilike(f"%{term}%"), + StandardRule.category_lv3.ilike(f"%{term}%"), + StandardRule.risk_point.ilike(f"%{term}%"), + ) + for term in terms + ] + statement = select(StandardRule).where(or_(*clauses)).order_by(StandardRule.id).limit(5) + rows = list(await self.session.scalars(statement)) + return [ + StandardJudgment( + category=row.category_lv1, + risk_point=row.risk_point, + rule=row.judge_standard, + source=row.regulation or "标准文件.xlsx", + status="待检测", + ) + for row in rows + ] + + @staticmethod + def _semantic_text(semantics: dict[str, Any], key: str) -> str | None: + value = semantics.get(key) + return str(value).strip() if value else None + + @staticmethod + def _first_match(text: str, values: Sequence[str]) -> str | None: + return next((value for value in values if value.lower() in text.lower()), None) + + @staticmethod + def _dimension_name(name: str) -> str: + return { + "supplierPerformance": "供应商表现", + "materialCompatibility": "物料品类", + "categoryHistory": "产品品类", + "productionControl": "加工厂质量", + "standardCompliance": "标准符合性", + "stageControl": "节点阶段", + "defectSeverity": "缺陷等级", + }.get(name, name) + + @staticmethod + def _risk_level_text(level: RiskLevel) -> str: + return { + RiskLevel.LOW: "低风险", + RiskLevel.MODERATELY_LOW: "较低风险", + RiskLevel.MEDIUM: "中等风险", + RiskLevel.HIGH: "高风险", + RiskLevel.VERY_HIGH: "极高风险", + }[level] + + @staticmethod + def _risk_badge(level: RiskLevel) -> str: + return { + RiskLevel.LOW: "正常推进", + RiskLevel.MODERATELY_LOW: "常规注意", + RiskLevel.MEDIUM: "需关注", + RiskLevel.HIGH: "加严管控", + RiskLevel.VERY_HIGH: "调整方案", + }[level] + + @staticmethod + def _supplier_tier(anomaly_count: int) -> str | None: + if not anomaly_count: + return "新供应商" + if anomaly_count < 20: + return "S级 · 战略" + if anomaly_count <= 60: + return "A级 · 优质" + if anomaly_count <= 120: + return "B级 · 关注" + return "C级 · 高风险" + + @staticmethod + def _alert_text( + category: str, + category_count: int, + supplier: str | None, + supplier_count: int, + text: str, + ) -> str: + parts = [] + if supplier and supplier_count: + parts.append(f"{supplier}相关历史异常 {supplier_count} 次") + if category_count: + parts.append(f"{category}历史异常 {category_count} 条") + if "软管" in text or "铝" in text: + parts.append("包装材质兼容性和密封性需要重点验证") + return ";".join(parts) + "。" if parts else "当前暂无足够历史数据,建议按标准库完成基础验证。" + + @staticmethod + def _suggestions(text: str, supplier: str | None) -> Sequence[str]: + suggestions = [] + if "软管" in text or "铝" in text: + suggestions.append("打样阶段增加内容物与包装材质兼容性测试") + if supplier: + suggestions.append(f"{supplier}首单安排来料全检并增加巡检频次") + if "量产" in text or "上市" in text: + suggestions.append("量产上市前完成密封性、稳定性和批次抽检验证") + suggestions.extend([ + "将检测结果回填标准判定记录,形成可追溯验证闭环", + "高贡献风险指标明确责任人与完成节点", + ]) + return suggestions[:6] + + @classmethod + def _is_risk_request(cls, text: str, semantics: dict[str, Any]) -> bool: + intent = str(semantics.get("intent") or "").strip().lower() + if intent in cls._risk_intents: + return True + requested = semantics.get("risk_requested") + if requested is True: + return True + return cls._is_explicit_risk_request(text) + + @classmethod + def _is_explicit_risk_request(cls, text: str) -> bool: + if any(keyword in text for keyword in cls._risk_keywords): + return True + matched_product_fields = sum(keyword in text for keyword in cls._product_assessment_keywords) + return matched_product_fields >= 2 + + @classmethod + def _normalize_semantics(cls, text: str, semantics: dict[str, Any]) -> dict[str, Any]: + """Keep deterministic product signals authoritative over occasional NLU misses.""" + + normalized = dict(semantics) + if cls._is_explicit_risk_request(text): + normalized["risk_requested"] = True + if str(normalized.get("intent") or "").lower() in {"", "general", "greet"}: + normalized["intent"] = "assessment" + elif normalized.get("risk_requested") is not False: + normalized["risk_requested"] = False + return normalized + + @staticmethod + def _title_for(conversation: Conversation, text: str) -> str: + if conversation.title != "新建对话": + return conversation.title + return text[:36] + ("…" if len(text) > 36 else "") + + @staticmethod + def _summary(conversation: Conversation) -> ConversationSummary: + return ConversationSummary( + id=conversation.id, + title=conversation.title, + message_count=len(conversation.messages), + last_risk_level=ConversationService._last_risk_level(conversation), + updated_at=conversation.updated_at, + ) + + @classmethod + def _data( + cls, + conversation: Conversation, + *, + messages: Sequence[ConversationMessage] | None = None, + total: int | None = None, + offset: int = 0, + risk_results: dict[int, RiskAssessmentResult] | None = None, + ) -> ConversationData: + resolved_messages = messages if messages is not None else conversation.messages + resolved_total = total if total is not None else len(resolved_messages) + return ConversationData( + id=conversation.id, + title=conversation.title, + message_count=resolved_total, + last_risk_level=ConversationService._last_risk_level_from_messages( + resolved_messages, + ) or ConversationService._last_risk_level(conversation), + updated_at=conversation.updated_at, + messages=[ + cls._message( + item, + risk_result=( + (risk_results or {}).get(item.id) + or cls._risk_result_for_message(resolved_messages, index) + ), + suppress_legacy=True, + ) + for index, item in enumerate(resolved_messages) + ], + messages_total=resolved_total, + messages_offset=offset, + has_more_messages=offset + len(resolved_messages) < resolved_total, + ) + + async def _refresh_risk_results( + self, + messages: Sequence[ConversationMessage], + ) -> dict[int, RiskAssessmentResult]: + """Refresh database-backed card fields for historical messages. + + Risk scores are intentionally kept as the persisted assessment result, + while fields sourced from imported cases and standards are re-read from + PostgreSQL. This prevents an old conversation from showing stale zero + counts after the reference workbooks have been imported. + """ + + refreshed: dict[int, RiskAssessmentResult] = {} + for index, message in enumerate(messages): + if message.role != "assistant": + continue + result = self._risk_result_for_message(messages, index) + if result is None: + continue + user_text = next( + ( + candidate.content + for candidate in reversed(messages[:index]) + if candidate.role == "user" + ), + result.subject_id, + ) + semantic = (message.structured_data or {}).get("semantic") + refreshed[message.id] = await self._enrich_risk_result( + user_text, + semantic if isinstance(semantic, dict) else {}, + result, + ) + return refreshed + + @staticmethod + def _message( + message: ConversationMessage, + *, + risk_result: RiskAssessmentResult | None = None, + suppress_legacy: bool = False, + ) -> ConversationMessageData: + raw_result = (message.structured_data or {}).get("riskResult") + return ConversationMessageData( + id=message.id, + role=message.role, + message_type=message.message_type or "text", + content=message.content, + risk_result=( + risk_result + if suppress_legacy + else RiskAssessmentResult.model_validate(raw_result) if raw_result else None + ), + semantic=(message.structured_data or {}).get("semantic"), + response_card=(message.structured_data or {}).get("responseCard"), + created_at=message.created_at, + ) + + @staticmethod + def _last_risk_level(conversation: Conversation) -> RiskLevel | None: + return ConversationService._last_risk_level_from_messages(conversation.messages) + + @staticmethod + def _last_risk_level_from_messages(messages: Sequence[ConversationMessage]) -> RiskLevel | None: + for index in range(len(messages) - 1, -1, -1): + risk_result = ConversationService._risk_result_for_message(messages, index) + if risk_result is not None: + return risk_result.level + return None + + @classmethod + def _risk_result_for_message( + cls, + messages: Sequence[ConversationMessage], + index: int, + ) -> RiskAssessmentResult | None: + """Return only a risk result that matches an explicit risk request. + + The semantic flag handles new LLM messages. The user-text fallback keeps + older records readable without displaying the risk card for old greetings + that were created before intent gating existed. + """ + + message = messages[index] + raw_result = (message.structured_data or {}).get("riskResult") + if not raw_result: + return None + semantic = (message.structured_data or {}).get("semantic") + if isinstance(semantic, dict): + if semantic.get("risk_requested") is False: + return None + if str(semantic.get("intent") or "").strip().lower() in {"greet", "general"}: + return None + if not isinstance(semantic, dict): + previous_user = next( + ( + candidate.content + for candidate in reversed(messages[:index]) + if candidate.role == "user" + ), + "", + ) + if not cls._is_explicit_risk_request(previous_user): + return None + try: + return RiskAssessmentResult.model_validate(raw_result) + except (TypeError, ValueError): + return None diff --git a/app/services/dashboard.py b/app/services/dashboard.py new file mode 100644 index 0000000..374e666 --- /dev/null +++ b/app/services/dashboard.py @@ -0,0 +1,389 @@ +"""Database-backed dashboard aggregation.""" + +from collections import Counter, defaultdict +from datetime import date, timedelta +from typing import Literal + +from sqlalchemy import func, select + +from app.models.entities import AnomalyCase, StandardRule +from app.schemas.dashboard import ( + DashboardCategory, + DashboardData, + DashboardDefect, + DashboardMatrixRow, + DashboardMetric, + DashboardStage, + DashboardSupplierRank, + DashboardTrendPoint, +) + + +class DashboardService: + _stages = ( + ("需求发现", "第一阶段"), + ("产品立项", "第二阶段"), + ("产品开发", "第三阶段"), + ("量产上市", "第四阶段"), + ("产品生命管理", "第五阶段"), + ) + _category_order = ( + "面霜/护肤类", + "洗发水/洗护类", + "沐浴露类", + "洁面/卸妆类", + "身体护理类", + "头发护理类", + "脱毛类", + "牙膏/口腔护理类", + "彩妆/唇部类", + "香氛/止汗类", + "清洁剂类", + "包材/物料专项", + "待补标品类", + ) + + def __init__(self, session) -> None: # noqa: ANN001 + self.session = session + + async def overview(self, year: Literal["all", "2024", "2025", "2026"]) -> DashboardData: + rows = list( + ( + await self.session.execute( + select( + AnomalyCase.date, + AnomalyCase.product_name, + AnomalyCase.stage_new, + AnomalyCase.anomaly_type, + AnomalyCase.material_type, + AnomalyCase.supplier, + AnomalyCase.factory, + AnomalyCase.defect_level, + ) + ) + ).mappings() + ) + filtered = [row for row in rows if self._in_year(row["date"], year)] + previous = self._previous_period(rows, year, filtered) + total = len(filtered) + previous_total = len(previous) + standards_count = int( + await self.session.scalar( + select(func.count()).select_from(StandardRule).where(StandardRule.status == "启用") + ) + or 0 + ) + defects = self._defects(filtered, total) + actors = self._actors(filtered) + high_risk_count = sum(1 for item in actors.values() if item["tier"] in {"B", "C"}) + ab_count = sum(item.count for item in defects if item.level in {"A", "B"}) + metrics = [ + DashboardMetric( + value=total, + label="异常案例总数", + change_percent=self._change(total, previous_total), + ), + DashboardMetric( + value=ab_count, + label="A+B级缺陷", + change_percent=self._change( + ab_count, + sum(1 for row in previous if row["defect_level"] in {"A", "B"}), + ), + ), + DashboardMetric( + value=high_risk_count, + label="高风险供应商/工厂", + change_percent=self._change( + high_risk_count, + sum(1 for item in self._actors(previous).values() if item["tier"] in {"B", "C"}), + ), + ), + DashboardMetric(value=None, label="预测准确率", change_percent=None), + DashboardMetric(value=standards_count, label="标准库覆盖", change_percent=None), + ] + stages = self._stages_data(filtered, total) + categories = self._categories_data(filtered, total) + matrix_categories = [item.name for item in categories] + matrix = self._matrix(filtered, matrix_categories) + ranking = self._ranking(actors) + trend = self._trend(filtered, year) + insight = self._insight(trend, stages, categories) + warnings = self._warnings(trend, categories, ranking) + return DashboardData( + year=year, + total_cases=total, + metrics=metrics, + stages=stages, + categories=categories, + matrix_categories=matrix_categories, + matrix=matrix, + defects=defects, + supplier_ranking=ranking, + trend=trend, + insight=insight, + warnings=warnings, + ) + + @staticmethod + def _in_year(value: date | None, year: str) -> bool: + return year == "all" or (value is not None and value.year == int(year)) + + @staticmethod + def _change(current: int, previous: int) -> float | None: + if not previous: + return None + return round((current - previous) / previous * 100, 1) + + @staticmethod + def _previous_period(rows, year: str, filtered) -> list: # noqa: ANN001 + if year != "all": + return [ + row + for row in rows + if row["date"] is not None and row["date"].year == int(year) - 1 + ] + dates = [row["date"] for row in filtered if row["date"] is not None] + if not dates: + return [] + current_end = max(dates) + current_start = current_end - timedelta(days=365) + previous_start = current_start - timedelta(days=365) + return [ + row + for row in rows + if row["date"] is not None + and previous_start <= row["date"] < current_start + ] + + @classmethod + def _category(cls, product_name: str | None, material_type: str | None = None) -> str: + value = (product_name or "").lower() + if "脱毛" in value: + return "脱毛类" + if any(word in value for word in ("牙膏", "牙粉", "漱口", "口腔")): + return "牙膏/口腔护理类" + if any(word in value for word in ("洁面", "洁颜", "洗面", "卸妆")): + return "洁面/卸妆类" + if any(word in value for word in ("护发", "发膜", "护发素", "染发")): + return "头发护理类" + if any(word in value for word in ("唇", "口红", "唇膏", "气垫", "粉底", "彩妆", "腮红")): + return "彩妆/唇部类" + if any(word in value for word in ("香氛", "香水", "香体", "走珠", "狐臭", "止汗")): + return "香氛/止汗类" + if any(word in value for word in ("身体乳", "护手霜", "磨砂", "搓泥", "防晒")): + return "身体护理类" + if any(word in value for word in ("面霜", "乳液", "精华", "面膜", "素颜霜", "爽肤水", "化妆水", "眼霜")): + return "面霜/护肤类" + if any(word in value for word in ("洗发", "洗发水", "洗发露")): + return "洗发水/洗护类" + if "沐浴" in value: + return "沐浴露类" + if "牙膏" in value: + return "牙膏类" + if any(word in value for word in ("清洁剂", "消毒", "洁厕", "洗衣")): + return "清洁剂类" + if material_type and material_type.strip() not in {"--", "-"}: + return "包材/物料专项" + return "待补标品类" + + @staticmethod + def _risk_level_by_ratio(ratio: float) -> str: + """Use the four visual bands defined by the dashboard prototype.""" + + if ratio >= 0.8: + return "高风险" + if ratio >= 0.6: + return "中高" + if ratio >= 0.35: + return "中等" + return "较低" + + @classmethod + def _stages_data(cls, rows, total: int) -> list[DashboardStage]: # noqa: ANN001 + counts = Counter() + for row in rows: + raw = row["stage_new"] or "" + for label, marker in cls._stages: + if label in raw or marker in raw: + counts[label] += 1 + break + maximum = max(counts.values(), default=0) + return [ + DashboardStage( + name=label, + count=counts[label], + share=round(counts[label] / total * 100, 1) if total else 0, + risk_level=cls._risk_level_by_ratio(counts[label] / maximum) if maximum else "较低", + ) + for label, _ in cls._stages + ] + + @classmethod + def _categories_data(cls, rows, total: int) -> list[DashboardCategory]: # noqa: ANN001 + grouped: dict[str, list] = defaultdict(list) + for row in rows: + grouped[cls._category(row["product_name"], row["material_type"])].append(row) + output = [] + for name in cls._category_order: + group = grouped.get(name, []) + if not group: + continue + materials = Counter(row["material_type"] for row in group if row["material_type"]) + output.append( + DashboardCategory( + name=name, + count=len(group), + share=round(len(group) / total * 100, 1) if total else 0, + risk_level="较低", + top_material=materials.most_common(1)[0][0] if materials else None, + ) + ) + output = [item for item in output if item.count] + maximum = max((item.count for item in output), default=0) + return [ + item.model_copy( + update={ + "risk_level": cls._risk_level_by_ratio(item.count / maximum) + if maximum + else "较低" + } + ) + for item in sorted(output, key=lambda item: (-item.count, cls._category_order.index(item.name))) + ] + + @staticmethod + def _tier(count: int, has_a: bool = False) -> str: + if count > 120 or has_a: + return "C" + if count > 60: + return "B" + if count >= 20: + return "A" + return "S" + + @classmethod + def _actors(cls, rows) -> dict[tuple[str, str], dict]: # noqa: ANN001 + grouped: dict[tuple[str, str], list] = defaultdict(list) + for row in rows: + for field, party_type in (("supplier", "供应商"), ("factory", "加工厂")): + name = (row[field] or "").strip() + if name and name != "--": + grouped[(name, party_type)].append(row) + return { + key: { + "count": len(items), + "has_a": any(row["defect_level"] == "A" for row in items), + "items": items, + "tier": cls._tier(len(items), any(row["defect_level"] == "A" for row in items)), + } + for key, items in grouped.items() + } + + @classmethod + def _matrix(cls, rows, categories: list[str]) -> list[DashboardMatrixRow]: # noqa: ANN001 + actors = cls._actors(rows) + result = [] + for (name, party_type), item in sorted(actors.items(), key=lambda entry: -entry[1]["count"]): + anomaly_count = item["count"] + # Keep the existing cooperation-total calculation unchanged. + cooperation_total = item["count"] + values = {category: 0 for category in categories} + for row in item["items"]: + category = cls._category(row["product_name"], row["material_type"]) + if category in values: + values[category] += 1 + result.append( + DashboardMatrixRow( + name=name, + party_type=party_type, + categories=values, + cooperation_total=cooperation_total, + anomaly_count=anomaly_count, + anomaly_rate=( + round(anomaly_count / cooperation_total * 100, 1) + if cooperation_total + else 0 + ), + tier=item["tier"], + ) + ) + return result + + @classmethod + def _ranking(cls, actors: dict[tuple[str, str], dict]) -> list[DashboardSupplierRank]: + ranking = [] + for (name, party_type), item in sorted(actors.items(), key=lambda entry: -entry[1]["count"]): + problems = Counter( + row["anomaly_type"] for row in item["items"] if row["anomaly_type"] + ) + ranking.append( + DashboardSupplierRank( + name=name, + party_type=party_type, + count=item["count"], + tier=item["tier"], + top_problem=problems.most_common(1)[0][0] if problems else None, + ) + ) + return ranking + + @staticmethod + def _defects(rows, total: int) -> list[DashboardDefect]: # noqa: ANN001 + labels = (("A", "A级缺陷"), ("B", "B级缺陷"), ("C", "C级缺陷"), ("D", "D级/未标注")) + counts = Counter(row["defect_level"] if row["defect_level"] in {"A", "B", "C"} else "D" for row in rows) + return [ + DashboardDefect( + level=level, + label=label, + count=counts[level], + share=round(counts[level] / total * 100, 1) if total else 0, + ) + for level, label in labels + ] + + @classmethod + def _trend(cls, rows, year: str) -> list[DashboardTrendPoint]: # noqa: ANN001 + dated = [row["date"] for row in rows if row["date"] is not None] + if not dated: + return [] + end = max(dated) + if year != "all": + end = date(int(year), 12, 1) + months = [] + current_year, current_month = end.year, end.month + for _ in range(12): + key = f"{current_year:04d}-{current_month:02d}" + count = sum(1 for value in dated if value.year == current_year and value.month == current_month) + months.append(DashboardTrendPoint(month=key, label=f"{current_month}月", count=count)) + current_month -= 1 + if current_month == 0: + current_year -= 1 + current_month = 12 + return list(reversed(months)) + + @staticmethod + def _insight(trend, stages, categories) -> str: # noqa: ANN001 + if len([item for item in trend if item.count]) < 3: + return "数据量不足,暂无法形成稳定的趋势洞察。" + recent = sum(item.count for item in trend[-3:]) + earlier = sum(item.count for item in trend[:3]) + direction = "上升" if recent > earlier * 1.1 else "下降" if recent < earlier * 0.9 else "基本平稳" + stage = max(stages, key=lambda item: item.count, default=None) + category = max(categories, key=lambda item: item.count, default=None) + stage_name = stage.name if stage else "产品开发" + category_name = category.name if category else "品类" + return f"近期开案趋势{direction};{stage_name}阶段异常最多,{category_name}是当前主要风险来源。" + + @staticmethod + def _warnings(trend, categories, ranking) -> list[str]: # noqa: ANN001 + warnings = [] + if trend and trend[-1].count > 10: + warnings.append(f"最近月份异常 {trend[-1].count} 条,已超过供应商月异常预警参考阈值。") + for item in ranking: + if item.count > 60: + warnings.append(f"{item.name}{item.party_type}累计异常 {item.count} 条,层级为 {item.tier} 级。") + for item in categories: + if item.count > 50: + warnings.append(f"{item.name}累计异常 {item.count} 条,建议重点关注{item.top_material or '高频物料'}。") + return warnings[:5] diff --git a/app/services/dingtalk_auth.py b/app/services/dingtalk_auth.py new file mode 100644 index 0000000..fdee144 --- /dev/null +++ b/app/services/dingtalk_auth.py @@ -0,0 +1,190 @@ +"""Server-side DingTalk auth-code exchange and local account upsert.""" + +import json +import time +from datetime import UTC, datetime + +import httpx + +from app.core.config import get_settings +from app.core.exceptions import AppException +from app.core.security import create_access_token +from app.models.entities import OperationAudit, UserAccount +from app.repositories.users import UserRepository +from app.schemas.auth import DingTalkConfigData, TokenData +from app.services.auth import AuthService + +TOKEN_URL = "https://oapi.dingtalk.com/gettoken" +USERINFO_URL = "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo" +USERDETAIL_URL = "https://oapi.dingtalk.com/topapi/v2/user/get" + + +class DingTalkServiceError(AppException): + def __init__(self, message: str = "钉钉服务暂时不可用,请稍后重试") -> None: + super().__init__(message, code=503, status_code=503) + + +class DingTalkAuthService: + _token_cache: tuple[str, float] | None = None + + def __init__(self, session) -> None: # noqa: ANN001 + self.session = session + self.repo = UserRepository(session) + + @staticmethod + def config() -> DingTalkConfigData: + settings = get_settings() + if not settings.dingtalk_app_key or not settings.dingtalk_agent_id or not settings.dingtalk_corp_id: + raise DingTalkServiceError("钉钉免登未配置,请联系管理员") + return DingTalkConfigData( + corp_id=settings.dingtalk_corp_id, + app_key=settings.dingtalk_app_key, + agent_id=settings.dingtalk_agent_id, + ) + + async def login(self, auth_code: str) -> TokenData: + settings = get_settings() + if not settings.dingtalk_app_secret or not settings.dingtalk_app_secret.get_secret_value().strip(): + raise DingTalkServiceError("钉钉免登未配置,请联系管理员") + access_token = await self._app_access_token() + user_id = await self._exchange_user_id(access_token, auth_code) + detail = await self._user_detail(access_token, user_id) + user = await self._upsert_user(user_id, detail) + if user.status != "启用": + raise AppException("用户账号已停用", code=401, status_code=401) + permissions = await self.repo.permissions_for_role(user.role) + permission_keys = sorted({item.func_key for item in permissions}) + self.session.add( + OperationAudit( + actor_user_id=user.id, + actor_emp_no=user.emp_no, + action="auth.dingtalk_login", + ) + ) + return TokenData( + access_token=create_access_token(user.id, role=user.role), + expires_in=settings.jwt_expire_minutes * 60, + user=AuthService.to_user_data(user, permission_keys), + ) + + async def _app_access_token(self) -> str: + settings = get_settings() + if self._token_cache and self._token_cache[1] > time.time() + 60: + return self._token_cache[0] + try: + async with httpx.AsyncClient(timeout=8) as client: + response = await client.get( + TOKEN_URL, + params={ + "appkey": settings.dingtalk_app_key, + "appsecret": settings.dingtalk_app_secret.get_secret_value(), + }, + ) + response.raise_for_status() + data = response.json() + except httpx.HTTPError as exc: + raise DingTalkServiceError() from exc + if data.get("errcode") or not data.get("access_token"): + raise DingTalkServiceError() + self._token_cache = (data["access_token"], time.time() + int(data.get("expires_in", 7200))) + return data["access_token"] + + async def _exchange_user_id(self, access_token: str, auth_code: str) -> str: + try: + async with httpx.AsyncClient(timeout=8) as client: + response = await client.post( + USERINFO_URL, + params={"access_token": access_token}, + json={"code": auth_code}, + ) + response.raise_for_status() + data = response.json() + except httpx.HTTPError as exc: + raise AppException("钉钉免登码已失效,请重新进入工作台", code=401, status_code=401) from exc + user_id = (data.get("result") or {}).get("userid") + if data.get("errcode") or not user_id: + raise AppException("钉钉免登码已失效,请重新进入工作台", code=401, status_code=401) + return str(user_id) + + async def _user_detail(self, access_token: str, user_id: str) -> dict: + try: + async with httpx.AsyncClient(timeout=8) as client: + response = await client.post( + USERDETAIL_URL, + params={"access_token": access_token}, + json={"userid": user_id, "language": "zh_CN"}, + ) + response.raise_for_status() + data = response.json() + except httpx.HTTPError: + return {"userid": user_id} + return data.get("result") or {"userid": user_id} + + async def _upsert_user(self, user_id: str, detail: dict) -> UserAccount: + user = await self.repo.get_by_dingtalk_id(user_id) + 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")), + ) + 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], + name=str(detail.get("name") or user_id)[:50], + role=role, + status="启用", + dingtalk_id=user_id, + ) + self.session.add(user) + user.name = str(detail.get("name") or user.name)[:50] + user.dept = str(detail.get("dept_name") or user.dept or "")[:50] or None + user.position = str(detail.get("title") or user.position or "")[:50] or None + user.role = role + user.dingtalk_union_id = str(detail.get("unionid") or user.dingtalk_union_id or "")[:64] or None + user.dingtalk_avatar = str(detail.get("avatar") or user.dingtalk_avatar or "")[:512] or None + user.dingtalk_job_number = str(detail.get("job_number") or user.dingtalk_job_number or "")[:64] or None + user.dingtalk_title = str(detail.get("title") or user.dingtalk_title or "")[:128] or None + dept_id = detail.get("main_dept_id") or detail.get("dept_id") or user.dingtalk_dept_id + if isinstance(dept_id, list): + dept_id = dept_id[0] if dept_id else None + try: + 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 + ) + 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) + user.dingtalk_role_codes = str(role_codes or "")[:512] or None + user.last_login_at = datetime.now(UTC) + user.dingtalk_id = user_id + user.updated_at = datetime.now(UTC) + await self.session.flush() + return user + + @staticmethod + def map_role(dept: str, title: str, is_admin: bool = False) -> str: + """Map DingTalk organization snapshots to the PRD seven-role model.""" + + 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 "procurement" in text: + return "procurement" + if "品控" in text or "质量" in text or "qc" in text: + return "qc" + if "管理" in text or "主管" in text or "manager" in text: + return "manager" + if "运营" in text or "ops" in text: + return "ops" + if "开发" in text or "产品" in text or "dev" in text: + return "dev" + return "普通用户" diff --git a/app/services/llm_client.py b/app/services/llm_client.py new file mode 100644 index 0000000..4d3266f --- /dev/null +++ b/app/services/llm_client.py @@ -0,0 +1,163 @@ +"""OpenAI-compatible LLM client for semantic extraction and streaming replies.""" + +import json +from collections.abc import AsyncGenerator +from typing import Any + +import httpx + +from app.core.config import get_settings +from app.core.exceptions import AppException + + +class LLMClient: + """Small provider-neutral client using the OpenAI chat-completions protocol.""" + + def __init__(self, transport: httpx.AsyncBaseTransport | None = None) -> None: + settings = get_settings() + self.api_key = settings.llm_api_key.get_secret_value() if settings.llm_api_key else "" + self.base_url = settings.llm_base_url.rstrip("/") + self.model = settings.llm_model + self.timeout = settings.llm_timeout_seconds + self.temperature = settings.llm_temperature + self.transport = transport + + @property + def configured(self) -> bool: + return bool(self.api_key) + + @property + def endpoint(self) -> str: + suffix = "/chat/completions" + return f"{self.base_url}{suffix}" if self.base_url.endswith("/v1") else f"{self.base_url}/v1{suffix}" + + def _headers(self) -> dict[str, str]: + if not self.configured: + raise AppException("未配置 LLM_API_KEY,暂时无法使用 AI 对话", code=503, status_code=503) + return { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + "Accept": "text/event-stream", + } + + async def extract_semantics( + self, + text: str, + history: list[dict[str, str]] | None = None, + ) -> dict[str, Any]: + """Extract structured product and intent fields before generating an answer.""" + + messages = [ + { + "role": "system", + "content": ( + "你是蝶雅产品风险预估系统的语义理解模块。" + "只输出合法 JSON,不要输出 Markdown。抽取用户输入中的意图和风险评估参数。" + "intent 只能是 greet、general、assessment、supplier、material、cases、" + "standard、compliance、solution 之一。" + "字段必须包含 intent, risk_requested, category, specification, packaging, " + "supplier, factory, stage, confidence。" + "只有用户明确要求风险评估、风险查询、标准判定、案例检索或预防方案时 risk_requested 才为 true;" + "用户描述要开发新品并提供规格、容量、包装材质、供应商或加工厂等参数时," + "即使没有出现‘风险’二字,也必须将 intent 设为 assessment、risk_requested 设为 true;" + "问候、闲聊、测试回复、要求原样回复等情况必须为 false。" + "缺失字段使用 null,confidence 为 0 到 1 的数字。" + ), + }, + ] + messages.extend(history or []) + messages.append({"role": "user", "content": text}) + body = await self._request(messages, stream=False, json_mode=True) + try: + content = body["choices"][0]["message"]["content"] + except (KeyError, IndexError, TypeError) as exc: + raise AppException("LLM 语义结果缺少 content", code=503, status_code=503) from exc + if isinstance(content, list): + content = "".join(str(part.get("text", "")) for part in content if isinstance(part, dict)) + return self._parse_json(str(content)) + + async def stream_reply( + self, + messages: list[dict[str, str]], + ) -> AsyncGenerator[str, None]: + """Yield text deltas from an OpenAI-compatible streaming response.""" + + headers = self._headers() + payload = { + "model": self.model, + "messages": messages, + "temperature": self.temperature, + "stream": True, + } + timeout = httpx.Timeout(self.timeout, connect=min(15, self.timeout)) + async with httpx.AsyncClient(timeout=timeout, transport=self.transport) as client: + async with client.stream("POST", self.endpoint, headers=headers, json=payload) as response: + if response.status_code >= 400: + detail = await response.aread() + raise AppException( + f"LLM 请求失败(HTTP {response.status_code})", + code=503, + status_code=503, + details=detail.decode("utf-8", errors="replace")[:500], + ) + async for line in response.aiter_lines(): + if not line.startswith("data:"): + continue + raw = line[5:].strip() + if not raw or raw == "[DONE]": + continue + try: + chunk = json.loads(raw) + except json.JSONDecodeError: + continue + choices = chunk.get("choices") or [] + if not choices: + continue + delta = (choices[0].get("delta") or {}).get("content") + if isinstance(delta, list): + delta = "".join( + str(part.get("text", "")) for part in delta if isinstance(part, dict) + ) + if delta: + yield str(delta) + + async def _request( + self, + messages: list[dict[str, str]], + *, + stream: bool, + json_mode: bool = False, + ) -> dict[str, Any]: + headers = self._headers() + payload: dict[str, Any] = { + "model": self.model, + "messages": messages, + "temperature": self.temperature, + "stream": stream, + } + if json_mode: + payload["response_format"] = {"type": "json_object"} + timeout = httpx.Timeout(self.timeout, connect=min(15, self.timeout)) + async with httpx.AsyncClient(timeout=timeout, transport=self.transport) as client: + try: + response = await client.post(self.endpoint, headers=headers, json=payload) + response.raise_for_status() + except httpx.HTTPError as exc: + raise AppException("LLM 语义理解请求失败", code=503, status_code=503) from exc + try: + return response.json() + except ValueError as exc: + raise AppException("LLM 返回格式无效", code=503, status_code=503) from exc + + @staticmethod + def _parse_json(content: str) -> dict[str, Any]: + cleaned = content.strip() + if cleaned.startswith("```"): + cleaned = cleaned.removeprefix("```").removeprefix("json").removesuffix("```").strip() + try: + parsed = json.loads(cleaned) + except json.JSONDecodeError as exc: + raise AppException("LLM 语义结果不是合法 JSON", code=503, status_code=503) from exc + if not isinstance(parsed, dict): + raise AppException("LLM 语义结果格式无效", code=503, status_code=503) + return parsed diff --git a/app/services/risk_items.py b/app/services/risk_items.py new file mode 100644 index 0000000..7f06c34 --- /dev/null +++ b/app/services/risk_items.py @@ -0,0 +1,166 @@ +"""Risk item submission, pre-review, and approval workflow.""" + +from datetime import UTC, datetime + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import AppException +from app.models.entities import AnomalyCase, OperationAudit, ReviewRecord, RiskItem, UserAccount +from app.schemas.risk_item import ReviewRecordData, RiskItemCreate, RiskItemData, RiskItemUpdate + + +class RiskItemService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def submit(self, payload: RiskItemCreate, user: UserAccount) -> RiskItemData: + pre_result, pre_note = self._pre_review(payload) + item = RiskItem( + source_type=payload.source_type, + source_file_id=payload.source_file_id, + product_name=payload.product_name, + product_category=payload.product_category, + supplier=payload.supplier, + factory=payload.factory, + stage_new=payload.stage_new, + description=payload.description, + defect_level=payload.defect_level, + solution=payload.solution, + photos=payload.photos, + submitted_by=user.emp_no, + submitted_at=datetime.now(UTC), + status="待审核" if pre_result == "通过" else "待预审", + ) + self.session.add(item) + await self.session.flush() + self.session.add( + ReviewRecord( + risk_item_id=item.id, + pre_review_result=pre_result, + pre_review_note=pre_note, + ) + ) + self.session.add( + OperationAudit( + actor_user_id=user.id, + actor_emp_no=user.emp_no, + action="risk_item.submit", + resource_type="risk_item", + resource_id=str(item.id), + audit_metadata={"source_type": payload.source_type, "pre_review": pre_result}, + ) + ) + await self.session.flush() + return self.to_data(item) + + async def list_pending( + self, + limit: int = 100, + *, + source_type: str = "all", + status: str = "待审核", + ) -> list[RiskItemData]: + filters = [] + if status != "all": + filters.append(RiskItem.status == status) + if source_type != "all": + filters.append(RiskItem.source_type == source_type) + statement = select(RiskItem).where(*filters).order_by(RiskItem.created_at.desc()).limit(limit) + return [self.to_data(item) for item in await self.session.scalars(statement)] + + async def update(self, item_id: int, payload: RiskItemUpdate, editor: UserAccount) -> RiskItemData: + item = await self.session.get(RiskItem, item_id) + if item is None: + raise AppException("风险项不存在", code=404, status_code=404) + if item.status not in {"待审核", "待预审"}: + raise AppException("只有待处理风险项可以编辑", code=409, status_code=409) + changes = payload.model_dump(exclude_unset=True) + if "defect_level" in changes and changes["defect_level"] not in {None, "A", "B", "C"}: + raise AppException("缺陷等级只能为 A、B 或 C", code=422, status_code=422) + for key, value in changes.items(): + setattr(item, key, value) + if item.status == "待预审" and len((item.description or "").strip()) >= 5: + item.status = "待审核" + record = await self.session.scalar( + select(ReviewRecord).where(ReviewRecord.risk_item_id == item_id).order_by(ReviewRecord.id.desc()) + ) + if record: + record.pre_review_result = "通过" + record.pre_review_note = None + self.session.add( + OperationAudit( + actor_user_id=editor.id, + actor_emp_no=editor.emp_no, + action="risk_item.edit", + resource_type="risk_item", + resource_id=str(item.id), + audit_metadata={"fields": list(changes)}, + ) + ) + await self.session.flush() + return self.to_data(item) + + async def review(self, item_id: int, action: str, note: str, reviewer: UserAccount) -> ReviewRecordData: + item = await self.session.get(RiskItem, item_id) + if item is None: + raise AppException("风险项不存在", code=404, status_code=404) + if item.status != "待审核": + raise AppException("该风险项当前不可审核", code=409, status_code=409) + if item.submitted_by == reviewer.emp_no: + raise AppException("审核人不能审核自己提交的风险项", code=403, status_code=403) + + record = await self.session.scalar( + select(ReviewRecord) + .where(ReviewRecord.risk_item_id == item_id) + .order_by(ReviewRecord.id.desc()) + ) + if record is None: + raise AppException("审核记录不存在", code=409, status_code=409) + record.reviewer = reviewer.emp_no + record.review_action = action + record.review_note = note + record.reviewed_at = datetime.now(UTC) + if action == "通过": + case = AnomalyCase( + product_name=item.product_name, + product_category=item.product_category, + supplier=item.supplier, + factory=item.factory, + stage_new=item.stage_new, + description=item.description, + defect_level=item.defect_level, + solution=item.solution, + source_type=item.source_type, + import_fingerprint=f"risk-item-{item.id}", + ) + self.session.add(case) + await self.session.flush() + record.case_id = case.id + item.status = "已通过" + else: + item.status = "已驳回" + self.session.add( + OperationAudit( + actor_user_id=reviewer.id, + actor_emp_no=reviewer.emp_no, + action="risk_item.review", + resource_type="risk_item", + resource_id=str(item.id), + audit_metadata={"review_action": action}, + ) + ) + await self.session.flush() + return ReviewRecordData.model_validate(record, from_attributes=True) + + @staticmethod + def _pre_review(payload: RiskItemCreate) -> tuple[str, str | None]: + if len(payload.description.strip()) < 5: + return "打回", "异常描述不能少于 5 个字符" + if payload.defect_level and payload.defect_level not in {"A", "B", "C"}: + return "打回", "缺陷等级只能为 A、B 或 C" + return "通过", None + + @staticmethod + def to_data(item: RiskItem) -> RiskItemData: + return RiskItemData.model_validate(item, from_attributes=True) diff --git a/app/services/risk_prediction.py b/app/services/risk_prediction.py new file mode 100644 index 0000000..9c16855 --- /dev/null +++ b/app/services/risk_prediction.py @@ -0,0 +1,42 @@ +"""Risk prediction use-case orchestration.""" + +from app.domain.risk_engine import BaselineRiskEngine, IndicatorValue +from app.schemas.risk_prediction import ( + ModelInfo, + RiskAssessmentRequest, + RiskAssessmentResult, + RiskFactorResult, +) + + +class RiskPredictionService: + _engine = BaselineRiskEngine() + + @classmethod + def model_info(cls) -> ModelInfo: + return ModelInfo(model_version=cls._engine.model_version) + + @classmethod + def preview(cls, request: RiskAssessmentRequest) -> RiskAssessmentResult: + score = cls._engine.predict( + [ + IndicatorValue(name=item.name, value=item.value, weight=item.weight) + for item in request.indicators + ] + ) + return RiskAssessmentResult( + subject_id=request.subject_id, + score=score.score, + level=score.level, + model_version=cls._engine.model_version, + factors=[ + RiskFactorResult( + name=item.name, + value=item.value, + weight=item.weight, + contribution=item.contribution, + ) + for item in score.factors + ], + ) + diff --git a/app/services/standards.py b/app/services/standards.py new file mode 100644 index 0000000..1ca9b68 --- /dev/null +++ b/app/services/standards.py @@ -0,0 +1,149 @@ +"""Standard library queries and administrator maintenance.""" + +from __future__ import annotations + +import hashlib +import math +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import AppException +from app.models.entities import OperationAudit, StandardRule, UserAccount +from app.schemas.standards import ( + StandardCategoryCount, + StandardRuleCreate, + StandardRuleDetail, + StandardRuleListData, + StandardRuleSummary, + StandardRuleUpdate, +) + + +class StandardLibraryService: + categories = ("内料", "包材", "包装", "法规") + + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def list( + self, + *, + keyword: str | None = None, + category: str = "all", + status: str = "启用", + page: int = 1, + page_size: int = 20, + ) -> StandardRuleListData: + filters = self._filters(keyword=keyword, category=category, status=status) + total = int(await self.session.scalar(select(func.count()).select_from(StandardRule).where(*filters)) or 0) + statement = ( + select(StandardRule) + .where(*filters) + .order_by(StandardRule.category_lv1, StandardRule.id) + .offset((page - 1) * page_size) + .limit(page_size) + ) + rows = list(await self.session.scalars(statement)) + category_counts = await self._category_counts(keyword=keyword, status=status) + return StandardRuleListData( + items=[self.to_summary(row) for row in rows], + total=total, + page=page, + page_size=page_size, + total_pages=math.ceil(total / page_size) if total else 0, + category_counts=category_counts, + ) + + async def get(self, rule_id: int) -> StandardRuleDetail: + row = await self.session.get(StandardRule, rule_id) + if row is None: + raise AppException("标准条目不存在", code=404, status_code=404) + return self.to_detail(row) + + async def create(self, payload: StandardRuleCreate, user: UserAccount) -> StandardRuleDetail: + row = StandardRule( + **payload.model_dump(), + import_fingerprint=self._manual_fingerprint(payload), + updated_at=datetime.now(UTC), + ) + self.session.add(row) + await self.session.flush() + await self._audit(user, "standard.create", row.id) + return self.to_detail(row) + + async def update(self, rule_id: int, payload: StandardRuleUpdate, user: UserAccount) -> StandardRuleDetail: + row = await self.session.get(StandardRule, rule_id) + if row is None: + raise AppException("标准条目不存在", code=404, status_code=404) + for key, value in payload.model_dump(exclude_unset=True).items(): + setattr(row, key, value) + row.updated_at = datetime.now(UTC) + await self._audit(user, "standard.update", row.id, {"fields": list(payload.model_fields_set)}) + await self.session.flush() + return self.to_detail(row) + + async def _category_counts(self, *, keyword: str | None, status: str) -> list[StandardCategoryCount]: + filters = self._filters(keyword=keyword, category="all", status=status) + rows = await self.session.execute( + select(StandardRule.category_lv1, func.count()) + .where(*filters) + .group_by(StandardRule.category_lv1) + ) + counts = dict(rows.all()) + return [ + StandardCategoryCount(category=category, count=int(counts.get(category, 0))) + for category in self.categories + ] + + @staticmethod + def _filters(*, keyword: str | None, category: str, status: str) -> list[Any]: + filters: list[Any] = [] + if category != "all": + if category not in StandardLibraryService.categories: + raise AppException("无效的标准类目", code=422, status_code=422) + filters.append(StandardRule.category_lv1 == category) + if status != "all": + if status not in {"启用", "停用"}: + raise AppException("无效的标准状态", code=422, status_code=422) + filters.append(StandardRule.status == status) + if keyword and keyword.strip(): + pattern = f"%{keyword.strip()}%" + filters.append( + or_( + StandardRule.category_lv1.ilike(pattern), + StandardRule.category_lv2.ilike(pattern), + StandardRule.category_lv3.ilike(pattern), + StandardRule.risk_point.ilike(pattern), + StandardRule.judge_standard.ilike(pattern), + StandardRule.regulation.ilike(pattern), + ) + ) + return filters + + async def _audit(self, user: UserAccount, action: str, rule_id: int, metadata: dict | None = None) -> None: + self.session.add( + OperationAudit( + actor_user_id=user.id, + actor_emp_no=user.emp_no, + action=action, + resource_type="standard_rule", + resource_id=str(rule_id), + audit_metadata=metadata or {}, + ) + ) + + @staticmethod + def _manual_fingerprint(payload: StandardRuleCreate) -> str: + content = "\x1f".join(str(value or "") for value in payload.model_dump().values()) + return hashlib.sha256(f"manual\x1f{content}".encode()).hexdigest() + + @staticmethod + def to_summary(row: StandardRule) -> StandardRuleSummary: + return StandardRuleSummary.model_validate(row, from_attributes=True) + + @staticmethod + def to_detail(row: StandardRule) -> StandardRuleDetail: + return StandardRuleDetail.model_validate(row, from_attributes=True) diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..839b3c9 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,42 @@ +# 后端架构 + +后端采用模块化分层单体。HTTP、业务编排、领域计算和持久化彼此隔离,后续可以替换风险模型而不改 API 层。 + +```text +HTTP Request + -> app/api/v1 路由、参数接收、响应 + -> app/services 用例编排 + -> app/domain 纯领域规则/模型适配器 + -> app/repositories 数据访问 + -> app/models SQLAlchemy ORM + -> PostgreSQL +``` + +- `app/core`:配置、日志和统一异常。 +- `app/database`:异步 Engine、Session 和事务边界。 +- `app/schemas`:Pydantic 请求/响应契约,外部 JSON 使用 camelCase。 +- `app/domain/risk_engine.py`:当前为可替换的基线评分引擎;未来可接 sklearn、XGBoost 或远程模型服务。 +- 风险评分遵循 PRD:输入值表示风险程度,先按 `S = 100 - X` 转为安全得分,再按权重求综合分;`R >= 80` 低风险,`60 <= R < 80` 较低风险,`40 <= R < 60` 中等风险,`20 <= R < 40` 高风险,`R < 20` 极高风险。 +- `migrations`:Alembic 数据库迁移。 +- `tests`:API 和领域服务测试。 + +认证与审核链路: + +```text +密码登录 / 钉钉 authCode + -> UserAccount + 角色映射 + -> JWT + -> 每次请求从 JWT 查用户并读取 role_permissions + -> 风险项自动预审 + -> 待审核队列 + -> 审核通过写入 anomaly_cases / 驳回退回 + -> operation_audits 追加式留痕 +``` + +## PRD 核心数据表 + +首期数据库包含 `anomaly_cases`、`supplier_profiles`、`risk_assessments`、`risk_items`、 +`review_records`、`standard_rules`、`user_accounts` 和 `role_permissions` 八张核心表。 + +目标 PostgreSQL 当前未安装 `pgvector`,因此 `anomaly_cases.embedding` 暂用可空 `JSONB` +保存浮点数组。安装 `vector` 扩展后,应通过独立迁移将该字段改为 `VECTOR(768)` 并补充向量索引。 diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..b2e03fe --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,60 @@ +"""Alembic asynchronous PostgreSQL migration environment.""" + +import asyncio +from logging.config import fileConfig + +from alembic import context +from app import models # noqa: F401 +from app.core.config import get_settings +from app.database.base import Base +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +config = context.config +settings = get_settings() +config.set_main_option("sqlalchemy.url", settings.database_url.replace("%", "%%")) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=settings.database_url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() + diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..4d9c6e0 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} + diff --git a/migrations/versions/20260828_0001_prd_core_schema.py b/migrations/versions/20260828_0001_prd_core_schema.py new file mode 100644 index 0000000..d9c821f --- /dev/null +++ b/migrations/versions/20260828_0001_prd_core_schema.py @@ -0,0 +1,192 @@ +"""Create the PRD core database schema. + +Revision ID: 20260828_0001 +Revises: +Create Date: 2026-08-28 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "20260828_0001" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "standard_rules", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("category_lv1", sa.String(length=20), nullable=False), + sa.Column("category_lv2", sa.String(length=60), nullable=True), + sa.Column("category_lv3", sa.String(length=60), nullable=True), + sa.Column("risk_point", sa.Text(), nullable=False), + sa.Column("judge_standard", sa.Text(), nullable=False), + sa.Column("regulation", sa.String(length=300), nullable=True), + sa.Column("attachment", sa.String(length=200), nullable=True), + sa.Column("status", sa.String(length=10), server_default="启用", nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_standard_rules_categories", "standard_rules", ["category_lv1", "category_lv2", "category_lv3"]) + op.create_index("ix_standard_rules_status", "standard_rules", ["status"]) + + op.create_table( + "anomaly_cases", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("date", sa.Date(), nullable=True), + sa.Column("product_code", sa.String(length=50), nullable=True), + sa.Column("product_name", sa.String(length=200), nullable=True), + sa.Column("product_category", sa.String(length=50), nullable=True), + sa.Column("stage_new", sa.String(length=50), nullable=True), + sa.Column("stage_old", sa.String(length=50), nullable=True), + sa.Column("anomaly_type", sa.String(length=50), nullable=True), + sa.Column("material_type", sa.String(length=50), nullable=True), + sa.Column("supplier", sa.String(length=100), nullable=True), + sa.Column("factory", sa.String(length=100), nullable=True), + sa.Column("person", sa.String(length=50), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("root_cause", sa.Text(), nullable=True), + sa.Column("solution", sa.Text(), nullable=True), + sa.Column("result", sa.String(length=50), nullable=True), + sa.Column("responsible", sa.String(length=100), nullable=True), + sa.Column("defect_level", sa.String(length=5), nullable=True), + sa.Column("is_resolved", sa.String(length=10), nullable=True), + sa.Column("source_file", sa.String(length=200), nullable=True), + sa.Column("source_type", sa.String(length=20), nullable=True), + sa.Column("standard_rule_id", sa.BigInteger(), nullable=True), + sa.Column("embedding", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["standard_rule_id"], ["standard_rules.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + for column in ("date", "product_category", "supplier", "factory", "material_type", "defect_level"): + op.create_index(f"ix_anomaly_cases_{column}", "anomaly_cases", [column]) + + op.create_table( + "supplier_profiles", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("supplier_name", sa.String(length=100), nullable=False), + sa.Column("total_anomalies", sa.Integer(), server_default="0", nullable=False), + sa.Column("frequency_coef", sa.Numeric(precision=4, scale=2), server_default="1.00", nullable=True), + sa.Column("tier", sa.String(length=1), nullable=True), + sa.Column("category_breakdown", postgresql.JSONB(astext_type=sa.Text()), server_default="{}", nullable=False), + sa.Column( + "product_cat_breakdown", + postgresql.JSONB(astext_type=sa.Text()), + server_default="{}", + nullable=False, + ), + sa.Column("stage_breakdown", postgresql.JSONB(astext_type=sa.Text()), server_default="{}", nullable=False), + sa.Column("monthly_trend", postgresql.JSONB(astext_type=sa.Text()), server_default="[]", nullable=False), + sa.Column("risk_score", sa.Integer(), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("supplier_name"), + ) + + op.create_table( + "risk_assessments", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.String(length=50), nullable=True), + sa.Column("product_name", sa.String(length=200), nullable=True), + sa.Column("params", postgresql.JSONB(astext_type=sa.Text()), server_default="{}", nullable=False), + sa.Column("risk_score", sa.Integer(), nullable=True), + sa.Column("risk_level", sa.String(length=10), nullable=True), + sa.Column("dimension_scores", postgresql.JSONB(astext_type=sa.Text()), server_default="{}", nullable=False), + sa.Column("similar_cases", postgresql.JSONB(astext_type=sa.Text()), server_default="{}", nullable=False), + sa.Column("ai_suggestions", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_risk_assessments_user_id", "risk_assessments", ["user_id"]) + op.create_index("ix_risk_assessments_created_at", "risk_assessments", ["created_at"]) + + op.create_table( + "risk_items", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("source_type", sa.String(length=20), nullable=False), + sa.Column("source_file_id", sa.String(length=50), nullable=True), + sa.Column("product_name", sa.String(length=200), nullable=True), + sa.Column("product_category", sa.String(length=50), nullable=True), + sa.Column("supplier", sa.String(length=100), nullable=True), + sa.Column("factory", sa.String(length=100), nullable=True), + sa.Column("stage_new", sa.String(length=50), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("defect_level", sa.String(length=5), nullable=True), + sa.Column("solution", sa.Text(), nullable=True), + sa.Column("photos", postgresql.JSONB(astext_type=sa.Text()), server_default="[]", nullable=False), + sa.Column("submitted_by", sa.String(length=50), nullable=True), + sa.Column("submitted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("status", sa.String(length=20), server_default="待预审", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_risk_items_status", "risk_items", ["status"]) + op.create_index("ix_risk_items_submitted_by", "risk_items", ["submitted_by"]) + op.create_index("ix_risk_items_submitted_at", "risk_items", ["submitted_at"]) + + op.create_table( + "user_accounts", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("emp_no", sa.String(length=20), nullable=False), + sa.Column("name", sa.String(length=50), nullable=False), + sa.Column("dept", sa.String(length=50), nullable=True), + sa.Column("position", sa.String(length=50), nullable=True), + sa.Column("role", sa.String(length=20), nullable=False), + sa.Column("status", sa.String(length=10), server_default="启用", nullable=False), + sa.Column("dingtalk_id", sa.String(length=50), nullable=True), + sa.Column("extra_perms", postgresql.JSONB(astext_type=sa.Text()), server_default="[]", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("dingtalk_id"), + sa.UniqueConstraint("emp_no"), + ) + + op.create_table( + "role_permissions", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("role", sa.String(length=20), nullable=False), + sa.Column("func_key", sa.String(length=50), nullable=False), + sa.Column("data_level", sa.String(length=10), server_default="L1", nullable=False), + sa.Column("can_view", sa.Boolean(), server_default="false", nullable=False), + sa.Column("can_operate", sa.Boolean(), server_default="false", nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_role_permissions_role", "role_permissions", ["role"]) + op.create_index("uq_role_permissions_role_func", "role_permissions", ["role", "func_key"], unique=True) + + op.create_table( + "review_records", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("risk_item_id", sa.BigInteger(), nullable=False), + sa.Column("pre_review_result", sa.String(length=20), nullable=True), + sa.Column("pre_review_note", sa.Text(), nullable=True), + sa.Column("reviewer", sa.String(length=50), nullable=True), + sa.Column("review_action", sa.String(length=20), nullable=True), + sa.Column("review_note", sa.Text(), nullable=True), + sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("case_id", sa.BigInteger(), nullable=True), + sa.ForeignKeyConstraint(["case_id"], ["anomaly_cases.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["risk_item_id"], ["risk_items.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_review_records_risk_item_id", "review_records", ["risk_item_id"]) + op.create_index("ix_review_records_reviewer", "review_records", ["reviewer"]) + + +def downgrade() -> None: + op.drop_table("review_records") + op.drop_table("role_permissions") + op.drop_table("user_accounts") + op.drop_table("risk_items") + op.drop_table("risk_assessments") + op.drop_table("supplier_profiles") + op.drop_table("anomaly_cases") + op.drop_table("standard_rules") diff --git a/migrations/versions/20260828_0002_add_prd_comments.py b/migrations/versions/20260828_0002_add_prd_comments.py new file mode 100644 index 0000000..8543801 --- /dev/null +++ b/migrations/versions/20260828_0002_add_prd_comments.py @@ -0,0 +1,164 @@ +"""Add PRD descriptions to tables and columns. + +Revision ID: 20260828_0002 +Revises: 20260828_0001 +Create Date: 2026-08-28 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "20260828_0002" +down_revision: str | None = "20260828_0001" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +COMMENTS = { + "standard_rules": { + "__table__": "判定标准库,保存内料、包材、包装和法规等四级风险判定标准。", + "id": "判定标准主键,自增编号。", + "category_lv1": "一级类目:内料、包材、包装或法规。", + "category_lv2": "二级类目:风险域。", + "category_lv3": "三级类目:风险子类。", + "risk_point": "四级类目:具体风险点。", + "judge_standard": "判定标准与依据,包括量化阈值和检测方法。", + "regulation": "法规或数据来源。", + "attachment": "标准原文或内部验收标准附件。", + "status": "标准状态:启用或停用。", + "updated_at": "标准最后更新时间。", + }, + "anomaly_cases": { + "__table__": "历史异常案例库,审核通过后供经验引擎检索和风险预测使用。", + "id": "异常案例主键,自增编号。", + "date": "异常发生日期。", + "product_code": "产品编码。", + "product_name": "产品名称。", + "product_category": "产品品类,例如洗发水、面霜等。", + "stage_new": "新版流程阶段。", + "stage_old": "旧版流程阶段。", + "anomaly_type": "异常类别。", + "material_type": "物料属性或物料类别。", + "supplier": "供应商名称。", + "factory": "加工厂名称。", + "person": "项目负责人。", + "description": "异常问题描述。", + "root_cause": "异常原因分析。", + "solution": "解决方案。", + "result": "处理结果。", + "responsible": "异常责任方。", + "defect_level": "缺陷等级:A、B 或 C。", + "is_resolved": "是否解决。", + "source_file": "数据来源文件。", + "source_type": "数据来源:导入、表单、语音或 OCR。", + "standard_rule_id": "关联的判定标准编号,可为空。", + "embedding": "语义向量;当前服务器未安装 pgvector,暂以 JSONB 浮点数组保存。", + "created_at": "案例入库时间。", + }, + "supplier_profiles": { + "__table__": "供应商风险画像及其统计汇总。", + "id": "供应商画像主键,自增编号。", + "supplier_name": "供应商名称,必须唯一。", + "total_anomalies": "该供应商的异常记录总数。", + "frequency_coef": "频次风险系数 alpha,按近三年异常次数计算并封顶 1.5。", + "tier": "供应商层级:S、A、B 或 C。", + "category_breakdown": "按物料类别统计的异常分布,例如 {彩盒: 60, 标贴: 30}。", + "product_cat_breakdown": "按产品品类统计的异常分布。", + "stage_breakdown": "按流程阶段统计的异常分布。", + "monthly_trend": "按月份统计的异常趋势。", + "risk_score": "供应商风险评分,范围 0-100。", + "updated_at": "供应商画像最后更新时间。", + }, + "risk_assessments": { + "__table__": "用户发起的风险评估记录及结构化结果。", + "id": "风险评估主键,自增编号。", + "user_id": "发起评估的用户标识。", + "product_name": "参与评估的产品名称。", + "params": "风险评估的原始输入参数。", + "risk_score": "综合风险评分,范围 0-100。", + "risk_level": "风险等级。", + "dimension_scores": "各风险维度评分,包含品类维度。", + "similar_cases": "相似案例及相似度信息,例如案例编号和相似度列表。", + "ai_suggestions": "AI 生成的风险预防建议。", + "created_at": "风险评估创建时间。", + }, + "risk_items": { + "__table__": "风险项录入暂存表,提交后进入自动预审和人工审核流程。", + "id": "风险项主键,自增编号。", + "source_type": "录入来源:表单、语音、OCR 或 Excel 导入。", + "source_file_id": "OCR 原始图片或报告附件标识。", + "product_name": "产品名称。", + "product_category": "产品品类。", + "supplier": "供应商名称。", + "factory": "加工厂名称。", + "stage_new": "新版流程阶段。", + "description": "风险或异常描述。", + "defect_level": "缺陷等级:A、B 或 C。", + "solution": "解决方案。", + "photos": "异常照片附件标识列表。", + "submitted_by": "提交人标识。", + "submitted_at": "提交时间。", + "status": "审核状态:待预审、待审核、已通过或已驳回。", + "created_at": "风险项创建时间。", + }, + "review_records": { + "__table__": "风险项审核记录,保留自动预审和人工审核全过程。", + "id": "审核记录主键,自增编号。", + "risk_item_id": "关联的风险项编号。", + "pre_review_result": "自动预审结果:通过或打回。", + "pre_review_note": "自动预审意见。", + "reviewer": "人工审核人标识。", + "review_action": "人工审核动作:通过或驳回。", + "review_note": "人工审核意见。", + "reviewed_at": "人工审核时间。", + "case_id": "审核通过后关联生成的异常案例编号,可为空。", + }, + "user_accounts": { + "__table__": "企业用户账号及其 RBAC 角色信息。", + "id": "用户账号主键,自增编号。", + "emp_no": "员工工号,必须唯一。", + "name": "用户姓名。", + "dept": "所属部门。", + "position": "岗位。", + "role": "角色:dev、procurement、qc、ops、reviewer、manager 或 admin。", + "status": "账号状态:启用或停用。", + "dingtalk_id": "钉钉账号标识,必须唯一,可为空。", + "extra_perms": "临时权限申请列表,包含功能标识和有效期。", + "created_at": "账号创建时间。", + "updated_at": "账号最后更新时间。", + }, + "role_permissions": { + "__table__": "角色权限矩阵,控制功能权限和数据分级访问。", + "id": "角色权限主键,自增编号。", + "role": "角色名称。", + "func_key": "功能标识,包括菜单、按钮或接口。", + "data_level": "数据访问级别:L1、L2 或 L3。", + "can_view": "是否允许查看。", + "can_operate": "是否允许操作,例如录入、审核或配置。", + "updated_at": "权限最后更新时间。", + }, +} + + +def _escape(value: str) -> str: + return value.replace("'", "''") + + +def upgrade() -> None: + for table_name, table_comments in COMMENTS.items(): + op.execute(f"COMMENT ON TABLE {table_name} IS '{_escape(table_comments['__table__'])}'") + for column_name, comment in table_comments.items(): + if column_name != "__table__": + op.execute( + f"COMMENT ON COLUMN {table_name}.{column_name} IS '{_escape(comment)}'" + ) + + +def downgrade() -> None: + for table_name, table_comments in COMMENTS.items(): + op.execute(f"COMMENT ON TABLE {table_name} IS NULL") + for column_name in table_comments: + if column_name != "__table__": + op.execute(f"COMMENT ON COLUMN {table_name}.{column_name} IS NULL") + diff --git a/migrations/versions/20260828_0003_add_import_source_fields.py b/migrations/versions/20260828_0003_add_import_source_fields.py new file mode 100644 index 0000000..6ed8ef3 --- /dev/null +++ b/migrations/versions/20260828_0003_add_import_source_fields.py @@ -0,0 +1,44 @@ +"""Preserve source-only fields and make Excel imports idempotent. + +Revision ID: 20260828_0003 +Revises: 20260828_0002 +Create Date: 2026-08-28 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260828_0003" +down_revision: str | None = "20260828_0002" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("anomaly_cases", sa.Column("brand", sa.String(length=100), nullable=True)) + op.add_column("anomaly_cases", sa.Column("specification", sa.String(length=100), nullable=True)) + op.add_column("anomaly_cases", sa.Column("remark", sa.Text(), nullable=True)) + op.add_column("anomaly_cases", sa.Column("import_fingerprint", sa.String(length=64), nullable=True)) + op.create_unique_constraint("uq_anomaly_cases_import_fingerprint", "anomaly_cases", ["import_fingerprint"]) + + op.add_column("standard_rules", sa.Column("import_fingerprint", sa.String(length=64), nullable=True)) + op.create_unique_constraint("uq_standard_rules_import_fingerprint", "standard_rules", ["import_fingerprint"]) + + op.execute("COMMENT ON COLUMN anomaly_cases.brand IS '源 Excel 中的品牌字段。'") + op.execute("COMMENT ON COLUMN anomaly_cases.specification IS '源 Excel 中的产品规格字段。'") + op.execute("COMMENT ON COLUMN anomaly_cases.remark IS '源 Excel 中的备注字段。'") + op.execute("COMMENT ON COLUMN anomaly_cases.import_fingerprint IS '源文件、工作表和行号生成的导入幂等指纹。'") + op.execute("COMMENT ON COLUMN standard_rules.import_fingerprint IS '源文件、工作表和行号生成的导入幂等指纹。'") + + +def downgrade() -> None: + op.drop_constraint("uq_standard_rules_import_fingerprint", "standard_rules", type_="unique") + op.drop_column("standard_rules", "import_fingerprint") + op.drop_constraint("uq_anomaly_cases_import_fingerprint", "anomaly_cases", type_="unique") + op.drop_column("anomaly_cases", "import_fingerprint") + op.drop_column("anomaly_cases", "remark") + op.drop_column("anomaly_cases", "specification") + op.drop_column("anomaly_cases", "brand") + diff --git a/migrations/versions/20260828_0004_widen_excel_text_columns.py b/migrations/versions/20260828_0004_widen_excel_text_columns.py new file mode 100644 index 0000000..5921488 --- /dev/null +++ b/migrations/versions/20260828_0004_widen_excel_text_columns.py @@ -0,0 +1,43 @@ +"""Widen imported anomaly columns to preserve source workbook text. + +Revision ID: 20260828_0004 +Revises: 20260828_0003 +Create Date: 2026-08-28 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260828_0004" +down_revision: str | None = "20260828_0003" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + for column_name, column_type in ( + ("product_code", sa.String(length=100)), + ("product_name", sa.String(length=500)), + ("product_category", sa.String(length=100)), + ("stage_new", sa.String(length=100)), + ("stage_old", sa.String(length=100)), + ("anomaly_type", sa.String(length=200)), + ("material_type", sa.String(length=200)), + ("supplier", sa.String(length=300)), + ("factory", sa.String(length=300)), + ("person", sa.String(length=200)), + ("result", sa.Text()), + ("responsible", sa.String(length=300)), + ("defect_level", sa.String(length=10)), + ("is_resolved", sa.Text()), + ("source_file", sa.String(length=300)), + ("source_type", sa.String(length=30)), + ): + op.alter_column("anomaly_cases", column_name, existing_type=sa.String(), type_=column_type) + + +def downgrade() -> None: + # Narrowing could truncate imported source text, so this is intentionally irreversible. + raise NotImplementedError("The widened import columns must not be narrowed after data import.") diff --git a/migrations/versions/20260828_0005_widen_standard_attachment.py b/migrations/versions/20260828_0005_widen_standard_attachment.py new file mode 100644 index 0000000..e36c3fa --- /dev/null +++ b/migrations/versions/20260828_0005_widen_standard_attachment.py @@ -0,0 +1,31 @@ +"""Widen standard attachment links for the supplied workbook. + +Revision ID: 20260828_0005 +Revises: 20260828_0004 +Create Date: 2026-08-28 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260828_0005" +down_revision: str | None = "20260828_0004" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.alter_column( + "standard_rules", + "attachment", + existing_type=sa.String(length=200), + type_=sa.String(length=500), + ) + + +def downgrade() -> None: + # Narrowing could truncate imported attachment links. + raise NotImplementedError("The standard attachment column must not be narrowed after import.") + diff --git a/migrations/versions/20260828_0006_add_auth_fields.py b/migrations/versions/20260828_0006_add_auth_fields.py new file mode 100644 index 0000000..ede12f4 --- /dev/null +++ b/migrations/versions/20260828_0006_add_auth_fields.py @@ -0,0 +1,32 @@ +"""Add password login and lockout fields to user accounts. + +Revision ID: 20260828_0006 +Revises: 20260828_0005 +Create Date: 2026-08-28 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260828_0006" +down_revision: str | None = "20260828_0005" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("user_accounts", sa.Column("password_hash", sa.String(length=255), nullable=True)) + op.add_column("user_accounts", sa.Column("login_failed_count", sa.Integer(), server_default="0", nullable=False)) + op.add_column("user_accounts", sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True)) + op.execute("COMMENT ON COLUMN user_accounts.password_hash IS '本地密码哈希,不保存明文密码;钉钉用户可为空。'") + op.execute("COMMENT ON COLUMN user_accounts.login_failed_count IS '连续登录失败次数,达到阈值后触发临时锁定。'") + op.execute("COMMENT ON COLUMN user_accounts.locked_until IS '本地登录临时锁定截止时间。'") + + +def downgrade() -> None: + op.drop_column("user_accounts", "locked_until") + op.drop_column("user_accounts", "login_failed_count") + op.drop_column("user_accounts", "password_hash") + diff --git a/migrations/versions/20260828_0007_add_user_email.py b/migrations/versions/20260828_0007_add_user_email.py new file mode 100644 index 0000000..697c0c3 --- /dev/null +++ b/migrations/versions/20260828_0007_add_user_email.py @@ -0,0 +1,30 @@ +"""Add optional enterprise email to user accounts. + +Revision ID: 20260828_0007 +Revises: 20260828_0006 +Create Date: 2026-08-28 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260828_0007" +down_revision: str | None = "20260828_0006" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("user_accounts", sa.Column("email", sa.String(length=128), nullable=True)) + op.create_unique_constraint("uq_user_accounts_email", "user_accounts", ["email"]) + op.create_index("ix_user_accounts_role_status", "user_accounts", ["role", "status"]) + op.execute("COMMENT ON COLUMN user_accounts.email IS '企业邮箱,可用于本地密码登录。'") + + +def downgrade() -> None: + op.drop_index("ix_user_accounts_role_status", table_name="user_accounts") + op.drop_constraint("uq_user_accounts_email", "user_accounts", type_="unique") + op.drop_column("user_accounts", "email") + diff --git a/migrations/versions/20260828_0008_dingtalk_and_audit.py b/migrations/versions/20260828_0008_dingtalk_and_audit.py new file mode 100644 index 0000000..5f48b24 --- /dev/null +++ b/migrations/versions/20260828_0008_dingtalk_and_audit.py @@ -0,0 +1,102 @@ +"""Add DingTalk organization fields and operation audit records. + +Revision ID: 20260828_0008 +Revises: 20260828_0007 +Create Date: 2026-08-28 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "20260828_0008" +down_revision: str | None = "20260828_0007" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + for column_name, column_type in ( + ("dingtalk_union_id", sa.String(length=64)), + ("dingtalk_avatar", sa.String(length=512)), + ("dingtalk_job_number", sa.String(length=64)), + ("dingtalk_title", sa.String(length=128)), + ("dingtalk_dept_id", sa.BigInteger()), + ("dingtalk_is_dept_manager", sa.Boolean()), + ("dingtalk_role_codes", sa.String(length=512)), + ("last_login_at", sa.DateTime(timezone=True)), + ): + if column_name == "dingtalk_is_dept_manager": + op.add_column( + "user_accounts", + sa.Column(column_name, column_type, server_default="false", nullable=False), + ) + else: + op.add_column("user_accounts", sa.Column(column_name, column_type, nullable=True)) + + comments = { + "dingtalk_union_id": "钉钉 UnionId。", + "dingtalk_avatar": "钉钉头像地址。", + "dingtalk_job_number": "钉钉组织中的工号。", + "dingtalk_title": "钉钉组织中的岗位或职务。", + "dingtalk_dept_id": "钉钉主部门编号。", + "dingtalk_is_dept_manager": "是否为部门主管。", + "dingtalk_role_codes": "钉钉角色编码快照。", + "last_login_at": "最近一次登录时间。", + } + for column_name, comment in comments.items(): + op.execute(f"COMMENT ON COLUMN user_accounts.{column_name} IS '{comment}'") + + op.create_table( + "operation_audits", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("actor_user_id", sa.BigInteger(), nullable=True), + sa.Column("actor_emp_no", sa.String(length=20), nullable=True), + sa.Column("action", sa.String(length=64), nullable=False), + sa.Column("resource_type", sa.String(length=64), nullable=True), + sa.Column("resource_id", sa.String(length=128), nullable=True), + sa.Column("result", sa.String(length=20), server_default="success", nullable=False), + sa.Column( + "metadata", + postgresql.JSONB(astext_type=sa.Text()), + server_default="{}", + nullable=False, + ), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_operation_audits_actor_created", + "operation_audits", + ["actor_user_id", "created_at"], + ) + op.create_index( + "ix_operation_audits_action_created", + "operation_audits", + ["action", "created_at"], + ) + op.execute("COMMENT ON TABLE operation_audits IS '登录、权限、审核等敏感操作的追加式审计记录。'") + op.execute( + "COMMENT ON COLUMN operation_audits.metadata IS " + "'审计上下文,禁止写入密码、令牌和完整敏感业务内容。'" + ) + + +def downgrade() -> None: + op.drop_index("ix_operation_audits_action_created", table_name="operation_audits") + op.drop_index("ix_operation_audits_actor_created", table_name="operation_audits") + op.drop_table("operation_audits") + for column_name in ( + "last_login_at", + "dingtalk_role_codes", + "dingtalk_is_dept_manager", + "dingtalk_dept_id", + "dingtalk_title", + "dingtalk_job_number", + "dingtalk_avatar", + "dingtalk_union_id", + ): + op.drop_column("user_accounts", column_name) + diff --git a/migrations/versions/20260828_0009_seed_rbac_permissions.py b/migrations/versions/20260828_0009_seed_rbac_permissions.py new file mode 100644 index 0000000..c1fc3bd --- /dev/null +++ b/migrations/versions/20260828_0009_seed_rbac_permissions.py @@ -0,0 +1,76 @@ +"""Seed the PRD role permission matrix. + +Revision ID: 20260828_0009 +Revises: 20260828_0008 +Create Date: 2026-08-28 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260828_0009" +down_revision: str | None = "20260828_0008" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +PERMISSIONS = ( + ("普通用户", "risk.assess", "L1", True, True), + ("普通用户", "risk_item.submit", "L1", True, True), + ("dev", "risk.assess", "L1", True, True), + ("dev", "risk_item.submit", "L1", True, True), + ("procurement", "risk.assess", "L1", True, True), + ("procurement", "supplier.read", "L2", True, False), + ("procurement", "risk_item.submit", "L1", True, True), + ("qc", "risk.assess", "L1", True, True), + ("qc", "risk_item.submit", "L1", True, True), + ("qc", "risk_item.review", "L2", True, False), + ("ops", "risk.assess", "L1", True, True), + ("ops", "risk_item.submit", "L1", True, True), + ("reviewer", "risk.assess", "L1", True, True), + ("reviewer", "risk_item.submit", "L1", True, True), + ("reviewer", "risk_item.review", "L2", True, True), + ("manager", "risk.assess", "L1", True, True), + ("manager", "supplier.read", "L2", True, False), + ("manager", "risk_item.submit", "L1", True, True), + ("manager", "risk_item.review", "L2", True, True), + ("admin", "risk.assess", "L3", True, True), + ("admin", "supplier.read", "L3", True, True), + ("admin", "risk_item.submit", "L3", True, True), + ("admin", "risk_item.review", "L3", True, True), + ("admin", "rbac.manage", "L3", True, True), +) + + +def upgrade() -> None: + connection = op.get_bind() + statement = sa.text( + """ + INSERT INTO role_permissions (role, func_key, data_level, can_view, can_operate) + VALUES (:role, :func_key, :data_level, :can_view, :can_operate) + ON CONFLICT (role, func_key) DO UPDATE SET + data_level = EXCLUDED.data_level, + can_view = EXCLUDED.can_view, + can_operate = EXCLUDED.can_operate, + updated_at = now() + """ + ) + for role, func_key, data_level, can_view, can_operate in PERMISSIONS: + connection.execute( + statement, + { + "role": role, + "func_key": func_key, + "data_level": data_level, + "can_view": can_view, + "can_operate": can_operate, + }, + ) + + +def downgrade() -> None: + connection = op.get_bind() + statement = sa.text("DELETE FROM role_permissions WHERE role = :role AND func_key = :func_key") + for role, func_key, *_ in PERMISSIONS: + connection.execute(statement, {"role": role, "func_key": func_key}) diff --git a/migrations/versions/20260830_0010_fix_risk_assess_permission.py b/migrations/versions/20260830_0010_fix_risk_assess_permission.py new file mode 100644 index 0000000..e636bc9 --- /dev/null +++ b/migrations/versions/20260830_0010_fix_risk_assess_permission.py @@ -0,0 +1,41 @@ +"""Allow business roles to execute risk assessments. + +Revision ID: 20260830_0010 +Revises: 20260828_0009 +Create Date: 2026-08-30 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260830_0010" +down_revision: str | None = "20260828_0009" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + sa.text( + """ + UPDATE role_permissions + SET can_operate = true, updated_at = now() + WHERE func_key = 'risk.assess' + """ + ) + ) + + +def downgrade() -> None: + op.execute( + sa.text( + """ + UPDATE role_permissions + SET can_operate = false, updated_at = now() + WHERE func_key = 'risk.assess' AND role <> 'admin' + """ + ) + ) + diff --git a/migrations/versions/20260831_0011_add_conversations.py b/migrations/versions/20260831_0011_add_conversations.py new file mode 100644 index 0000000..aa6b335 --- /dev/null +++ b/migrations/versions/20260831_0011_add_conversations.py @@ -0,0 +1,58 @@ +"""Add persistent assistant conversations and messages. + +Revision ID: 20260831_0011 +Revises: 20260830_0010 +Create Date: 2026-08-31 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "20260831_0011" +down_revision: str | None = "20260830_0010" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "conversations", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.Column("title", sa.String(length=120), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["user_accounts.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_conversations_user_updated", "conversations", ["user_id", "updated_at"]) + + op.create_table( + "conversation_messages", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("conversation_id", sa.BigInteger(), nullable=False), + sa.Column("role", sa.String(length=20), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column( + "structured_data", + postgresql.JSONB(astext_type=sa.Text()), + server_default="{}", + nullable=False, + ), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["conversation_id"], ["conversations.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_conversation_messages_conversation_created", + "conversation_messages", + ["conversation_id", "created_at"], + ) + + +def downgrade() -> None: + op.drop_table("conversation_messages") + op.drop_table("conversations") diff --git a/migrations/versions/20260831_0012_add_conversation_message_type.py b/migrations/versions/20260831_0012_add_conversation_message_type.py new file mode 100644 index 0000000..fd7203f --- /dev/null +++ b/migrations/versions/20260831_0012_add_conversation_message_type.py @@ -0,0 +1,27 @@ +"""Add message type to persistent assistant messages. + +Revision ID: 20260831_0012 +Revises: 20260831_0011 +Create Date: 2026-08-31 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260831_0012" +down_revision: str | None = "20260831_0011" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "conversation_messages", + sa.Column("message_type", sa.String(length=20), server_default="text", nullable=False), + ) + + +def downgrade() -> None: + op.drop_column("conversation_messages", "message_type") diff --git a/migrations/versions/20260902_0013_seed_standard_permissions.py b/migrations/versions/20260902_0013_seed_standard_permissions.py new file mode 100644 index 0000000..223d125 --- /dev/null +++ b/migrations/versions/20260902_0013_seed_standard_permissions.py @@ -0,0 +1,56 @@ +"""Add permissions for the standard library.""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260902_0013" +down_revision: str | None = "20260831_0012" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +PERMISSIONS = ( + ("普通用户", "standard.read", "L1", True, False), + ("dev", "standard.read", "L1", True, False), + ("procurement", "standard.read", "L1", True, False), + ("qc", "standard.read", "L1", True, False), + ("ops", "standard.read", "L1", True, False), + ("reviewer", "standard.read", "L1", True, False), + ("manager", "standard.read", "L1", True, False), + ("admin", "standard.read", "L3", True, True), + ("admin", "standard.manage", "L3", True, True), +) + + +def upgrade() -> None: + connection = op.get_bind() + statement = sa.text( + """ + INSERT INTO role_permissions (role, func_key, data_level, can_view, can_operate) + VALUES (:role, :func_key, :data_level, :can_view, :can_operate) + ON CONFLICT (role, func_key) DO UPDATE SET + data_level = EXCLUDED.data_level, + can_view = EXCLUDED.can_view, + can_operate = EXCLUDED.can_operate, + updated_at = now() + """ + ) + for role, func_key, data_level, can_view, can_operate in PERMISSIONS: + connection.execute( + statement, + { + "role": role, + "func_key": func_key, + "data_level": data_level, + "can_view": can_view, + "can_operate": can_operate, + }, + ) + + +def downgrade() -> None: + connection = op.get_bind() + statement = sa.text("DELETE FROM role_permissions WHERE role = :role AND func_key = :func_key") + for role, func_key, *_ in PERMISSIONS: + connection.execute(statement, {"role": role, "func_key": func_key}) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7a738ba --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,48 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "dieya-risk-prediction-backend" +version = "0.1.0" +description = "Risk prediction service built with FastAPI" +readme = "README.md" +requires-python = ">=3.11.9,<3.12" +dependencies = [ + "alembic>=1.13,<2.0", + "asyncpg>=0.29,<1.0", + "fastapi>=0.115,<1.0", + "httpx>=0.27,<1.0", + "openpyxl>=3.1,<4.0", + "PyJWT>=2.8,<3.0", + "pydantic>=2.8,<3.0", + "pydantic-settings>=2.4,<3.0", + "sqlalchemy[asyncio]>=2.0,<3.0", + "uvicorn[standard]>=0.30,<1.0", +] + +[project.optional-dependencies] +dev = [ + "aiosqlite>=0.20,<1.0", + "httpx>=0.27,<1.0", + "pytest>=8.0,<9.0", + "pytest-asyncio>=0.23,<1.0", + "ruff>=0.6,<1.0", +] + +[tool.setuptools.packages.find] +include = ["app*"] + +[tool.ruff] +line-length = 120 +target-version = "py311" +src = ["app", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +addopts = "-ra" +asyncio_mode = "auto" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..33183a0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +alembic>=1.13,<2.0 +asyncpg>=0.29,<1.0 +fastapi>=0.115,<1.0 +httpx>=0.27,<1.0 +openpyxl>=3.1,<4.0 +PyJWT>=2.8,<3.0 +pydantic>=2.8,<3.0 +pydantic-settings>=2.4,<3.0 +sqlalchemy[asyncio]>=2.0,<3.0 +uvicorn[standard]>=0.30,<1.0 diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..77ab4a4 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1,2 @@ +"""Operational scripts for the risk prediction backend.""" + diff --git a/scripts/create_user.py b/scripts/create_user.py new file mode 100644 index 0000000..f4a1b03 --- /dev/null +++ b/scripts/create_user.py @@ -0,0 +1,61 @@ +"""Create or update a local user account with a hashed password.""" + +from __future__ import annotations + +import argparse +import asyncio + +from app.core.config import get_settings +from app.core.security import hash_password +from app.models.entities import UserAccount +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + + +async def create_or_update_user( + emp_no: str, + name: str, + password: str, + role: str, + email: str | None, +) -> int: + settings = get_settings() + engine = create_async_engine(settings.database_url, pool_pre_ping=True) + session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + try: + async with session_factory() as session: + user = await session.scalar(select(UserAccount).where(UserAccount.emp_no == emp_no)) + if user is None: + user = UserAccount(emp_no=emp_no, name=name, role=role, status="启用") + session.add(user) + user.name = name + user.role = role + user.status = "启用" + user.password_hash = hash_password(password) + user.email = email + await session.commit() + return user.id + finally: + await engine.dispose() + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--emp-no", required=True, help="Employee number used for login") + parser.add_argument("--name", required=True, help="Display name") + parser.add_argument("--password", required=True, help="Plain password; only its hash is stored") + parser.add_argument( + "--role", + default="admin", + choices=("普通用户", "dev", "procurement", "qc", "ops", "reviewer", "manager", "admin"), + ) + parser.add_argument("--email") + args = parser.parse_args() + if len(args.password) < 8: + parser.error("password must be at least 8 characters") + user_id = asyncio.run(create_or_update_user(args.emp_no, args.name, args.password, args.role, args.email)) + print(f"user_id={user_id}") + + +if __name__ == "__main__": + main() diff --git a/scripts/import_excel_data.py b/scripts/import_excel_data.py new file mode 100644 index 0000000..cd7d78a --- /dev/null +++ b/scripts/import_excel_data.py @@ -0,0 +1,195 @@ +"""Import the supplied anomaly and judgment-standard workbooks. + +The import is intentionally idempotent: every source row gets a stable SHA-256 +fingerprint based on its file, worksheet, and Excel row number. +""" + +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import logging +import re +from collections.abc import Iterable, Sequence +from datetime import date, datetime +from pathlib import Path +from typing import Any + +from app.core.config import get_settings +from app.models.entities import AnomalyCase, StandardRule +from openpyxl import load_workbook +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +LOGGER = logging.getLogger("dieya_risk_prediction.import") + +ANOMALY_SHEET = "异常总览(新流程分类)" +STANDARD_SHEETS = ("包装", "包材", "法规", "内料") + + +def clean_text(value: Any) -> str | None: + """Normalize spreadsheet text while preserving meaningful content.""" + + if value is None: + return None + text = str(value).replace("\u200b", "").strip() + return text or None + + +def parse_date(value: Any) -> date | None: + """Parse the date formats used by the source workbook.""" + + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + text = clean_text(value) + if not text: + return None + text = re.sub(r"\s+00:00:00$", "", text) + text = text.replace("年", "-").replace("月", "-").replace("日", "") + for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d"): + try: + return datetime.strptime(text, fmt).date() + except ValueError: + continue + month_only = re.fullmatch(r"(\d{4})[-/.](\d{1,2})", text) + if month_only: + return date(int(month_only.group(1)), int(month_only.group(2)), 1) + return None + + +def fingerprint(file_name: str, sheet_name: str, row_number: int) -> str: + source = f"{file_name}\x1f{sheet_name}\x1f{row_number}" + return hashlib.sha256(source.encode("utf-8")).hexdigest() + + +def row_values(row: Sequence[Any], size: int) -> list[Any]: + """Return a fixed-size row so trailing empty cells are handled uniformly.""" + + values = list(row) + return values[:size] + [None] * max(0, size - len(values)) + + +def read_anomaly_rows(path: Path) -> list[dict[str, Any]]: + workbook = load_workbook(path, data_only=True) + sheet = workbook[ANOMALY_SHEET] + rows: list[dict[str, Any]] = [] + for excel_row, raw_row in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2): + values = row_values(raw_row, 22) + rows.append( + { + "date": parse_date(values[1]), + "product_code": clean_text(values[2]), + "product_name": clean_text(values[3]), + "brand": clean_text(values[4]), + "specification": clean_text(values[5]), + "stage_new": clean_text(values[6]), + "stage_old": clean_text(values[7]), + "anomaly_type": clean_text(values[8]), + "material_type": clean_text(values[9]), + "supplier": clean_text(values[10]), + "factory": clean_text(values[11]), + "person": clean_text(values[12]), + "description": clean_text(values[13]), + "root_cause": clean_text(values[14]), + "solution": clean_text(values[15]), + "result": clean_text(values[16]), + "responsible": clean_text(values[17]), + "defect_level": clean_text(values[18]) if clean_text(values[18]) != "/" else None, + "is_resolved": clean_text(values[19]), + "source_file": clean_text(values[20]) or path.name, + "remark": clean_text(values[21]), + "source_type": "Excel导入", + "import_fingerprint": fingerprint(path.name, ANOMALY_SHEET, excel_row), + } + ) + return rows + + +def read_standard_rows(path: Path) -> list[dict[str, Any]]: + workbook = load_workbook(path, data_only=True) + rows: list[dict[str, Any]] = [] + for sheet_name in STANDARD_SHEETS: + sheet = workbook[sheet_name] + for excel_row, raw_row in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2): + values = row_values(raw_row, 9) + if not any(value is not None and str(value).strip() for value in values): + continue + rows.append( + { + "category_lv1": clean_text(values[1]) or sheet_name, + "category_lv2": clean_text(values[2]), + "category_lv3": clean_text(values[3]), + "risk_point": clean_text(values[4]) or "未填写", + "judge_standard": clean_text(values[5]) or "未填写", + "regulation": clean_text(values[6]), + "attachment": " | ".join( + value for value in (clean_text(values[7]), clean_text(values[8])) if value + ) or None, + "status": "启用", + "import_fingerprint": fingerprint(path.name, sheet_name, excel_row), + } + ) + return rows + + +async def insert_ignore_duplicates( + session: AsyncSession, + model: type[AnomalyCase] | type[StandardRule], + rows: Iterable[dict[str, Any]], +) -> tuple[int, int]: + payload = list(rows) + if not payload: + return 0, 0 + inserted = 0 + batch_size = 100 + for start in range(0, len(payload), batch_size): + batch = payload[start : start + batch_size] + statement = insert(model).values(batch).on_conflict_do_nothing(index_elements=["import_fingerprint"]) + result = await session.execute(statement) + inserted += result.rowcount or 0 + return inserted, len(payload) - inserted + + +async def import_workbooks(anomaly_path: Path, standard_path: Path) -> dict[str, int]: + anomaly_rows = read_anomaly_rows(anomaly_path) + standard_rows = read_standard_rows(standard_path) + settings = get_settings() + engine = create_async_engine(settings.database_url, pool_pre_ping=True) + session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + try: + async with session_factory() as session: + anomaly_inserted, anomaly_skipped = await insert_ignore_duplicates(session, AnomalyCase, anomaly_rows) + standard_inserted, standard_skipped = await insert_ignore_duplicates(session, StandardRule, standard_rows) + await session.commit() + finally: + await engine.dispose() + return { + "anomaly_source_rows": len(anomaly_rows), + "anomaly_inserted": anomaly_inserted, + "anomaly_skipped": anomaly_skipped, + "standard_source_rows": len(standard_rows), + "standard_inserted": standard_inserted, + "standard_skipped": standard_skipped, + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--anomaly-file", type=Path, required=True) + parser.add_argument("--standard-file", type=Path, required=True) + args = parser.parse_args() + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s %(message)s") + if not args.anomaly_file.is_file(): + parser.error(f"anomaly file does not exist: {args.anomaly_file}") + if not args.standard_file.is_file(): + parser.error(f"standard file does not exist: {args.standard_file}") + result = asyncio.run(import_workbooks(args.anomaly_file, args.standard_file)) + for key, value in result.items(): + print(f"{key}={value}") + + +if __name__ == "__main__": + main() diff --git a/services_dummy.txt b/services_dummy.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..612f475 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,2 @@ +"""Backend tests.""" + diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..6051758 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,10 @@ +from app.services.dingtalk_auth import DingTalkAuthService + + +def test_dingtalk_role_mapping() -> None: + assert DingTalkAuthService.map_role("产品开发部", "工程师") == "dev" + assert DingTalkAuthService.map_role("品控部", "质量主管") == "qc" + assert DingTalkAuthService.map_role("采购部", "采购专员") == "procurement" + assert DingTalkAuthService.map_role("", "审核员") == "reviewer" + assert DingTalkAuthService.map_role("", "", is_admin=True) == "admin" + assert DingTalkAuthService.map_role("未知部门", "普通员工") == "普通用户" diff --git a/tests/test_conversation_context.py b/tests/test_conversation_context.py new file mode 100644 index 0000000..5d65dc6 --- /dev/null +++ b/tests/test_conversation_context.py @@ -0,0 +1,45 @@ +from datetime import UTC, datetime + +from app.models.entities import Conversation, ConversationMessage +from app.services.conversations import ConversationService + + +def test_history_context_does_not_duplicate_current_user_message() -> None: + current_text = "继续分析这个产品的供应商风险" + conversation = Conversation( + id=1, + user_id=1, + title="产品风险", + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + messages=[ + ConversationMessage( + id=1, + conversation_id=1, + role="user", + content="评估一款软管新品", + created_at=datetime.now(UTC), + ), + ConversationMessage( + id=2, + conversation_id=1, + role="assistant", + content="已识别为软管新品风险评估。", + created_at=datetime.now(UTC), + ), + ConversationMessage( + id=3, + conversation_id=1, + role="user", + content=current_text, + created_at=datetime.now(UTC), + ), + ], + ) + + history = ConversationService._history_messages(conversation, current_text=current_text) + + assert [item["content"] for item in history] == [ + "评估一款软管新品", + "已识别为软管新品风险评估。", + ] diff --git a/tests/test_conversations.py b/tests/test_conversations.py new file mode 100644 index 0000000..165781a --- /dev/null +++ b/tests/test_conversations.py @@ -0,0 +1,121 @@ +from datetime import UTC, datetime + +from app.models.entities import Conversation, ConversationMessage +from app.services.conversations import ConversationService + + +def test_conversation_message_data_reads_persisted_risk_result() -> None: + message = ConversationMessage( + id=10, + conversation_id=3, + role="assistant", + content="已完成风险预估。", + structured_data={ + "riskResult": { + "subjectId": "软管新品", + "score": 82, + "level": "low", + "modelVersion": "baseline-safety-v2", + "factors": [], + } + }, + created_at=datetime.now(UTC), + ) + + result = ConversationService._message(message) + + assert result.role == "assistant" + assert result.risk_result is not None + assert result.risk_result.score == 82 + assert result.risk_result.level.value == "low" + + +def test_conversation_data_includes_all_messages() -> None: + conversation = Conversation( + id=3, + user_id=1, + title="软管新品评估", + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + messages=[ + ConversationMessage( + id=1, + conversation_id=3, + role="user", + content="评估软管新品", + created_at=datetime.now(UTC), + ), + ConversationMessage( + id=2, + conversation_id=3, + role="assistant", + content="已完成分析", + created_at=datetime.now(UTC), + ), + ], + ) + + result = ConversationService._data(conversation) + + assert result.message_count == 2 + assert [message.role for message in result.messages] == ["user", "assistant"] + + +def test_only_explicit_risk_requests_generate_risk_cards() -> None: + assert ConversationService._is_risk_request( + "你好", + {"intent": "greet", "risk_requested": False}, + ) is False + assert ConversationService._is_risk_request( + "请只回复:AI 连通成功。", + {"intent": "general", "risk_requested": False}, + ) is False + assert ConversationService._is_risk_request( + "请评估这个产品的风险", + {"intent": "assessment", "risk_requested": True}, + ) is True + + +def test_legacy_general_semantics_hide_old_risk_card() -> None: + messages = [ + ConversationMessage( + id=1, + conversation_id=1, + role="user", + content="你好", + created_at=datetime.now(UTC), + ), + ConversationMessage( + id=2, + conversation_id=1, + role="assistant", + content="你好,我可以帮你做什么?", + structured_data={ + "semantic": {"intent": "general"}, + "riskResult": { + "subjectId": "误判", + "score": 61, + "level": "moderately_low", + "modelVersion": "baseline-safety-v2", + "factors": [], + }, + }, + created_at=datetime.now(UTC), + ), + ] + + assert ConversationService._risk_result_for_message(messages, 1) is None + assert ConversationService._is_risk_request( + "我要做一款脱毛膏新品,100g,PE软管包装,加工厂准备使用瑞沣", + {"intent": "general", "risk_requested": False}, + ) is True + + +def test_product_assessment_semantics_override_nlu_miss() -> None: + normalized = ConversationService._normalize_semantics( + "我要做一款脱毛膏新品,100g,PE软管包装,加工厂准备使用瑞沣", + {"intent": "general", "risk_requested": False, "category": "脱毛膏"}, + ) + + assert normalized["intent"] == "assessment" + assert normalized["risk_requested"] is True diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..2b65fcf --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,18 @@ +from app.main import app +from fastapi.testclient import TestClient + + +def test_health_check() -> None: + response = TestClient(app).get("/api/v1/health") + + assert response.status_code == 200 + assert response.json() == { + "code": 200, + "message": "success", + "data": { + "status": "ok", + "service": "Dieya Risk Prediction Backend", + "version": "0.1.0", + }, + } + diff --git a/tests/test_llm_client.py b/tests/test_llm_client.py new file mode 100644 index 0000000..d5ff8d5 --- /dev/null +++ b/tests/test_llm_client.py @@ -0,0 +1,38 @@ +import pytest +from app.core.config import get_settings +from app.core.exceptions import AppException +from app.services.llm_client import LLMClient + + +def test_llm_endpoint_supports_openai_v1_base_url(monkeypatch: pytest.MonkeyPatch) -> None: + get_settings.cache_clear() + monkeypatch.setenv("RISK_LLM_API_KEY", "test-key") + monkeypatch.setenv("RISK_LLM_BASE_URL", "https://example.test/v1") + client = LLMClient() + + assert client.configured + assert client.endpoint == "https://example.test/v1/chat/completions" + get_settings.cache_clear() + + +def test_llm_client_accepts_reference_project_environment(monkeypatch: pytest.MonkeyPatch) -> None: + get_settings.cache_clear() + monkeypatch.delenv("RISK_LLM_API_KEY", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "reference-key") + monkeypatch.setenv("LLM_BASE_URL", "https://reference.test/v1") + monkeypatch.setenv("LLM_MODEL", "reference-model") + client = LLMClient() + + assert client.configured + assert client.endpoint == "https://reference.test/v1/chat/completions" + assert client.model == "reference-model" + get_settings.cache_clear() + + +def test_llm_client_parses_json_and_rejects_non_object() -> None: + parsed = LLMClient._parse_json('```json\n{"intent":"assessment","confidence":0.9}\n```') + + assert parsed["intent"] == "assessment" + assert parsed["confidence"] == 0.9 + with pytest.raises(AppException): + LLMClient._parse_json("[]") diff --git a/tests/test_llm_protocol.py b/tests/test_llm_protocol.py new file mode 100644 index 0000000..c8ee269 --- /dev/null +++ b/tests/test_llm_protocol.py @@ -0,0 +1,57 @@ +import json + +import httpx +import pytest +from app.core.config import get_settings +from app.services.llm_client import LLMClient + + +@pytest.mark.asyncio +async def test_openai_compatible_semantic_and_stream_protocol(monkeypatch: pytest.MonkeyPatch) -> None: + get_settings.cache_clear() + monkeypatch.setenv("RISK_LLM_API_KEY", "test-key") + monkeypatch.setenv("RISK_LLM_BASE_URL", "https://llm.test/v1") + monkeypatch.setenv("RISK_LLM_MODEL", "test-model") + calls = 0 + + async def handler(request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + if calls == 1: + return httpx.Response( + 200, + json={ + "choices": [ + { + "message": { + "content": json.dumps( + {"intent": "assessment", "category": "洗发水", "confidence": 0.92}, + ensure_ascii=False, + ) + } + } + ] + }, + ) + return httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=( + 'data: {"choices":[{"delta":{"content":"第一段"}}]}\n\n' + 'data: {"choices":[{"delta":{"content":"第二段"}}]}\n\n' + "data: [DONE]\n\n" + ).encode(), + ) + + client = LLMClient(transport=httpx.MockTransport(handler)) + semantic = await client.extract_semantics("评估洗发水新品") + chunks = [ + chunk + async for chunk in client.stream_reply([{"role": "user", "content": "继续分析"}]) + ] + + assert semantic["intent"] == "assessment" + assert semantic["confidence"] == 0.92 + assert "".join(chunks) == "第一段第二段" + assert calls == 2 + get_settings.cache_clear() diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..0cb8b82 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,49 @@ +from app.database.base import Base +from app.models import ( # noqa: F401 + AnomalyCase, + Conversation, + ConversationMessage, + OperationAudit, + ReviewRecord, + RiskAssessment, + RiskItem, + RolePermission, + StandardRule, + SupplierProfile, + UserAccount, +) +from sqlalchemy import BigInteger + + +def test_prd_core_tables_are_registered() -> None: + assert set(Base.metadata.tables) == { + "anomaly_cases", + "conversations", + "conversation_messages", + "operation_audits", + "review_records", + "risk_assessments", + "risk_items", + "role_permissions", + "standard_rules", + "supplier_profiles", + "user_accounts", + } + + +def test_prd_tables_use_bigint_primary_keys() -> None: + for table in Base.metadata.sorted_tables: + assert isinstance(table.c.id.type, BigInteger) + assert table.c.id.primary_key + + +def test_review_and_standard_foreign_keys_are_registered() -> None: + review_targets = { + foreign_key.target_fullname for foreign_key in Base.metadata.tables["review_records"].foreign_keys + } + anomaly_targets = { + foreign_key.target_fullname for foreign_key in Base.metadata.tables["anomaly_cases"].foreign_keys + } + + assert review_targets == {"risk_items.id", "anomaly_cases.id"} + assert anomaly_targets == {"standard_rules.id"} diff --git a/tests/test_risk_prediction.py b/tests/test_risk_prediction.py new file mode 100644 index 0000000..4ca66cb --- /dev/null +++ b/tests/test_risk_prediction.py @@ -0,0 +1,47 @@ +from app.domain.risk_engine import BaselineRiskEngine, IndicatorValue +from app.main import app +from fastapi.testclient import TestClient + + +def test_preview_requires_authentication() -> None: + response = TestClient(app).post( + "/api/v1/risk-predictions/preview", + json={ + "subjectId": "customer-001", + "indicators": [ + {"name": "debtBurden", "value": 80, "weight": 3}, + {"name": "cashflowVolatility", "value": 20, "weight": 1}, + ], + }, + ) + + assert response.status_code == 401 + + +def test_baseline_engine_calculates_weighted_score() -> None: + score = BaselineRiskEngine().predict( + [ + IndicatorValue(name="debtBurden", value=80, weight=3), + IndicatorValue(name="cashflowVolatility", value=20, weight=1), + ] + ) + + assert score.score == 35.0 + assert score.level.value == "high" + assert [factor.contribution for factor in score.factors] == [15.0, 20.0] + + +def test_risk_level_boundaries_follow_prd() -> None: + engine = BaselineRiskEngine() + cases = ( + (80, "low"), + (60, "moderately_low"), + (40, "medium"), + (20, "high"), + (19.99, "very_high"), + ) + + for score, expected_level in cases: + result = engine.predict([IndicatorValue(name="risk", value=100 - score, weight=1)]) + assert result.score == score + assert result.level.value == expected_level