Files
AiTool/backend/app/routers/projects.py
2026-03-18 17:01:10 +08:00

457 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Union
import json
from typing import AsyncGenerator
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session, joinedload
from backend.app import models
from backend.app.db import get_db
from backend.app.schemas import (
ContractGenerateRequest,
ContractGenerateResponse,
ProjectRead,
ProjectUpdate,
PushToCloudRequest,
PushToCloudResponse,
QuoteGenerateResponse,
RequirementAnalyzeRequest,
RequirementAnalyzeResponse,
)
from backend.app.services.ai_service import analyze_requirement
from backend.app.services.cloud_doc_service import CloudDocManager
from backend.app.services.doc_service import (
generate_contract_word,
generate_quote_excel,
generate_quote_pdf_from_data,
)
from backend.app.routers.cloud_doc_config import get_all_credentials
router = APIRouter(prefix="/projects", tags=["projects"])
def _build_markdown_from_analysis(data: Union[Dict[str, Any], List[Any]]) -> str:
"""
Convert structured AI analysis JSON into a human-editable Markdown document.
Tolerates AI returning a list (e.g. modules only) and normalizes to a dict.
"""
if isinstance(data, list):
data = {"modules": data, "total_estimated_hours": None, "total_amount": None, "notes": None}
if not isinstance(data, dict):
data = {}
lines: list[str] = []
lines.append("# 项目方案草稿")
lines.append("")
total_hours = data.get("total_estimated_hours")
total_amount = data.get("total_amount")
if total_hours is not None or total_amount is not None:
lines.append("## 概要")
if total_hours is not None:
lines.append(f"- 建议总工时:**{total_hours}**")
if total_amount is not None:
lines.append(f"- 建议总报价:**{total_amount}**")
lines.append("")
modules = data.get("modules") or []
if modules:
lines.append("## 功能模块与技术实现")
for idx, module in enumerate(modules, start=1):
if not isinstance(module, dict):
# AI sometimes returns strings or other shapes; treat as a single title line
raw_name = str(module).strip() if module else ""
name = raw_name if len(raw_name) > 1 and raw_name not in (":", "[", "{", "}") else f"模块 {idx}"
lines.append(f"### {idx}. {name}")
lines.append("")
continue
raw_name = (module.get("name") or "").strip()
name = raw_name if len(raw_name) > 1 and raw_name not in (":", "[", "{", "}") else f"模块 {idx}"
desc = module.get("description") or ""
tech = module.get("technical_approach") or ""
hours = module.get("estimated_hours")
unit_price = module.get("unit_price")
subtotal = module.get("subtotal")
lines.append(f"### {idx}. {name}")
if desc:
lines.append(desc)
lines.append("")
if tech:
lines.append("**技术实现思路:**")
lines.append(tech)
lines.append("")
if hours is not None or unit_price is not None or subtotal is not None:
lines.append("**工时与报价:**")
if hours is not None:
lines.append(f"- 预估工时:{hours}")
if unit_price is not None:
lines.append(f"- 单价:{unit_price}")
if subtotal is not None:
lines.append(f"- 小计:{subtotal}")
lines.append("")
notes = data.get("notes")
if notes:
lines.append("## 备注")
lines.append(notes)
lines.append("")
return "\n".join(lines)
@router.get("/", response_model=list[ProjectRead])
async def list_projects(
customer_tag: str | None = None,
limit: int = Query(30, ge=1, le=200, description="默认只返回最近 N 条"),
db: Session = Depends(get_db),
):
"""列表项目customer_tag 不为空时只返回该客户标签下的项目(按客户 tags 筛选)。"""
query = (
db.query(models.Project)
.options(joinedload(models.Project.customer))
.join(models.Customer)
.order_by(models.Project.created_at.desc())
)
if customer_tag and customer_tag.strip():
tag = customer_tag.strip()
# 客户 tags 逗号分隔,按整词匹配
from sqlalchemy import or_
t = models.Customer.tags
query = query.filter(
or_(
t == tag,
t.ilike(f"{tag},%"),
t.ilike(f"%,{tag},%"),
t.ilike(f"%,{tag}"),
)
)
return query.limit(limit).all()
@router.get("/{project_id}", response_model=ProjectRead)
async def get_project(project_id: int, db: Session = Depends(get_db)):
project = db.query(models.Project).get(project_id)
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
return project
@router.patch("/{project_id}", response_model=ProjectRead)
async def update_project(
project_id: int,
payload: ProjectUpdate,
db: Session = Depends(get_db),
):
project = db.query(models.Project).get(project_id)
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
if payload.raw_requirement is not None:
project.raw_requirement = payload.raw_requirement
if payload.ai_solution_md is not None:
project.ai_solution_md = payload.ai_solution_md
if payload.status is not None:
project.status = payload.status
db.commit()
db.refresh(project)
return project
@router.post("/analyze", response_model=RequirementAnalyzeResponse)
async def analyze_project_requirement(
payload: RequirementAnalyzeRequest,
db: Session = Depends(get_db),
):
logging.getLogger(__name__).info(
"收到 AI 解析请求: customer_id=%s, 需求长度=%d",
payload.customer_id,
len(payload.raw_text or ""),
)
# Ensure customer exists
customer = db.query(models.Customer).get(payload.customer_id)
if not customer:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found")
try:
analysis = await analyze_requirement(payload.raw_text)
except RuntimeError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
ai_solution_md = _build_markdown_from_analysis(analysis)
project = models.Project(
customer_id=payload.customer_id,
raw_requirement=payload.raw_text,
ai_solution_md=ai_solution_md,
status="draft",
)
db.add(project)
db.commit()
db.refresh(project)
return RequirementAnalyzeResponse(
project_id=project.id,
ai_solution_md=ai_solution_md,
ai_solution_json=analysis,
)
@router.post("/analyze_stream")
async def analyze_project_requirement_stream(
payload: RequirementAnalyzeRequest,
db: Session = Depends(get_db),
):
"""
SSE 流式输出 Markdown 到前端(用于编辑器实时显示)。
data: {"type":"delta","content":"..."} / {"type":"done","project_id":1}
"""
customer = db.query(models.Customer).get(payload.customer_id)
if not customer:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found")
# Create a project first so we can return project_id at end
project = models.Project(
customer_id=payload.customer_id,
raw_requirement=payload.raw_text,
ai_solution_md="",
status="draft",
)
db.add(project)
db.commit()
db.refresh(project)
async def gen() -> AsyncGenerator[str, None]:
from backend.app.services.ai_service import get_active_ai_config
from openai import AsyncOpenAI
config = get_active_ai_config()
api_key = (config.get("api_key") or "").strip()
if not api_key:
yield f"data: {json.dumps({'type':'error','message':'AI API Key 未配置'})}\n\n"
return
base_url = (config.get("base_url") or "").strip() or None
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
model = config.get("model_name") or "gpt-4o-mini"
temperature = float(config.get("temperature", 0.2))
system_prompt = (
(config.get("system_prompt_override") or "").strip()
or "你是一名资深系统架构师,请输出可直接编辑的 Markdown 方案,不要输出 JSON。"
)
user_prompt = (
"请基于以下客户原始需求输出一份可交付的项目方案草稿Markdown\n"
"要求包含:概要、功能模块拆分、技术实现思路、工时与报价估算、备注。\n\n"
f"【客户原始需求】\n{payload.raw_text}"
)
full = ""
try:
stream = await client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
temperature=temperature,
stream=True,
)
async for event in stream:
delta = (event.choices[0].delta.content or "") if event.choices else ""
if not delta:
continue
full += delta
yield f"data: {json.dumps({'type':'delta','content':delta}, ensure_ascii=False)}\n\n"
except Exception as exc:
yield f"data: {json.dumps({'type':'error','message':str(exc)}, ensure_ascii=False)}\n\n"
return
# Save final markdown
try:
project.ai_solution_md = full
db.add(project)
db.commit()
except Exception:
pass
yield f"data: {json.dumps({'type':'done','project_id':project.id}, ensure_ascii=False)}\n\n"
return StreamingResponse(gen(), media_type="text/event-stream")
@router.post("/{project_id}/generate_quote", response_model=QuoteGenerateResponse)
async def generate_project_quote(
project_id: int,
template: str | None = None,
db: Session = Depends(get_db),
):
project = db.query(models.Project).get(project_id)
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
# Re-analyze to get fresh structured data (the UI will allow user to edit Markdown separately).
analysis = await analyze_requirement(project.raw_requirement)
# Paths
base_dir = Path("data/quotes")
base_dir.mkdir(parents=True, exist_ok=True)
excel_path = base_dir / f"quote_project_{project.id}.xlsx"
pdf_path = base_dir / f"quote_project_{project.id}.pdf"
from backend.app.routers.settings import get_quote_template_path
template_path = get_quote_template_path(template)
if not template_path.exists():
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Quote template file not found on server.",
)
await generate_quote_excel(analysis, str(template_path), str(excel_path))
await generate_quote_pdf_from_data(analysis, str(pdf_path))
total_amount = float(analysis.get("total_amount") or 0.0)
quote = models.Quote(
project_id=project.id,
total_amount=total_amount,
file_path=str(pdf_path),
)
db.add(quote)
db.commit()
db.refresh(quote)
return QuoteGenerateResponse(
quote_id=quote.id,
project_id=project.id,
total_amount=total_amount,
excel_path=str(excel_path),
pdf_path=str(pdf_path),
)
@router.post(
"/{project_id}/generate_contract",
response_model=ContractGenerateResponse,
)
async def generate_project_contract(
project_id: int,
payload: ContractGenerateRequest,
db: Session = Depends(get_db),
):
project = db.query(models.Project).get(project_id)
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
customer = project.customer
if not customer:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Project has no associated customer"
)
# Use the latest quote for this project to determine total price.
latest_quote = (
db.query(models.Quote)
.filter(models.Quote.project_id == project.id)
.order_by(models.Quote.created_at.desc())
.first()
)
if not latest_quote:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No quote found for this project.",
)
contracts_dir = Path("data/contracts")
contracts_dir.mkdir(parents=True, exist_ok=True)
output_path = contracts_dir / f"contract_project_{project.id}.docx"
template_path = Path("templates/contract_template.docx")
if not template_path.exists():
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Contract template file not found on server.",
)
# Build placeholder mapping
mapping: Dict[str, str] = {
"{{CUSTOMER_NAME}}": customer.name,
"{{TOTAL_PRICE}}": str(latest_quote.total_amount),
"{{DELIVERY_DATE}}": payload.delivery_date,
}
# Allow arbitrary additional placeholders
for key, value in payload.extra_placeholders.items():
mapping[key] = value
await generate_contract_word(mapping, str(template_path), str(output_path))
return ContractGenerateResponse(project_id=project.id, contract_path=str(output_path))
@router.post("/{project_id}/push-to-cloud", response_model=PushToCloudResponse)
async def push_project_to_cloud(
project_id: int,
payload: PushToCloudRequest,
db: Session = Depends(get_db),
):
"""
将当前项目方案Markdown推送到云文档。若该项目此前已推送过该平台则更新原文档增量同步
"""
project = db.query(models.Project).options(joinedload(models.Project.customer)).get(project_id)
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
platform = (payload.platform or "").strip().lower()
if platform not in ("feishu", "yuque", "tencent"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="platform 须为 feishu / yuque / tencent",
)
title = (payload.title or "").strip() or f"项目方案 - 项目#{project_id}"
body_md = (payload.body_md if payload.body_md is not None else project.ai_solution_md) or ""
if not body_md.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="暂无方案内容,请先在编辑器中填写或保存方案后再推送",
)
existing = (
db.query(models.ProjectCloudDoc)
.filter(
models.ProjectCloudDoc.project_id == project_id,
models.ProjectCloudDoc.platform == platform,
)
.first()
)
existing_doc_id = existing.cloud_doc_id if existing else None
credentials = get_all_credentials()
manager = CloudDocManager(credentials)
try:
cloud_doc_id, url = await manager.push_markdown(
platform, title, body_md, existing_doc_id=existing_doc_id
)
except RuntimeError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
if existing:
existing.cloud_doc_id = cloud_doc_id
existing.cloud_url = url
existing.updated_at = datetime.now(timezone.utc)
else:
record = models.ProjectCloudDoc(
project_id=project_id,
platform=platform,
cloud_doc_id=cloud_doc_id,
cloud_url=url,
)
db.add(record)
db.commit()
return PushToCloudResponse(url=url, cloud_doc_id=cloud_doc_id)