437 lines
18 KiB
Python
437 lines
18 KiB
Python
"""Saved idea service for the Personal Space pre-project layer."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import Any
|
||
from uuid import uuid4
|
||
|
||
from sqlalchemy import and_, desc, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from ..config.ai_prompts import PROMPT_SAVED_IDEA_ENRICHMENT
|
||
from ..models import SavedIdea
|
||
from ..schemas.pmflow_launch import PMFlowLaunchAttachment, PMFlowLaunchDraftRequest
|
||
from ..schemas.space import SavedIdeaCreateRequest, SavedIdeaRead, SavedIdeaUpdateRequest
|
||
from ..services.llm_service import llm_service
|
||
from ..services.operation_log_service import log_operation
|
||
from ..services.pmflow_launch_service import PMFlowLaunchService
|
||
from ..services.ai_routing_governance_service import PLATFORM_RUNTIME_TENANT_ID
|
||
from ..services.prompt_runtime_service import PromptRuntimeRenderResult, PromptRuntimeService
|
||
from ..utils.timezone import now_cn_naive
|
||
|
||
|
||
STATUS_LABELS = {
|
||
"saved": "未整理",
|
||
"enriched": "已整理",
|
||
"needs_more_info": "信息不足",
|
||
"draft_ready": "可生成草案",
|
||
"promoted": "已转为草案",
|
||
"archived": "已归档",
|
||
}
|
||
|
||
|
||
class SavedIdeaError(RuntimeError):
|
||
def __init__(self, code: str, message: str, *, status_code: int = 400):
|
||
super().__init__(message)
|
||
self.code = code
|
||
self.message = message
|
||
self.status_code = int(status_code)
|
||
|
||
|
||
class SpaceSavedIdeaService:
|
||
ENRICHMENT_PROMPT_KEY = "PROMPT_SAVED_IDEA_ENRICHMENT"
|
||
|
||
def __init__(self, db: AsyncSession, *, tenant_id: int):
|
||
self.db = db
|
||
self.tenant_id = int(tenant_id)
|
||
|
||
async def list_ideas(
|
||
self,
|
||
*,
|
||
user_id: int,
|
||
limit: int = 20,
|
||
include_archived: bool = False,
|
||
) -> list[SavedIdeaRead]:
|
||
stmt = select(SavedIdea).where(
|
||
and_(SavedIdea.tenant_id == self.tenant_id, SavedIdea.user_id == int(user_id))
|
||
)
|
||
if not include_archived:
|
||
stmt = stmt.where(SavedIdea.status != "archived")
|
||
stmt = stmt.order_by(desc(SavedIdea.updated_at), desc(SavedIdea.id)).limit(max(1, min(int(limit), 100)))
|
||
rows = (await self.db.execute(stmt)).scalars().all()
|
||
return [self._to_read(row) for row in rows]
|
||
|
||
async def create_idea(self, *, body: SavedIdeaCreateRequest, user_id: int) -> SavedIdeaRead:
|
||
raw_text = self._clean_text(body.raw_text)
|
||
if not raw_text:
|
||
raise SavedIdeaError("input_empty", "暂存想法内容不能为空", status_code=400)
|
||
title = self._clean_text(body.title or "") or self._derive_title(raw_text)
|
||
enrichment = await self._enrich_or_baseline(raw_text)
|
||
summary = self._clean_text(body.summary or "") or str(enrichment["summary"])
|
||
row = SavedIdea(
|
||
tenant_id=self.tenant_id,
|
||
user_id=int(user_id),
|
||
idea_id=f"idea_{uuid4().hex[:16]}",
|
||
title=title[:255],
|
||
raw_text=raw_text,
|
||
summary=summary,
|
||
source_type=str(body.source_type or "project_launch")[:32],
|
||
source_ref=self._clean_text(body.source_ref or "")[:255] or None,
|
||
input_mode=str(body.input_mode or "idea")[:32],
|
||
attachments=[item.model_dump(mode="json") for item in body.attachments],
|
||
status=str(enrichment["status"]),
|
||
ai_summary=enrichment.get("ai_summary"),
|
||
missing_questions=enrichment["missing_questions"],
|
||
readiness_score=float(enrichment["readiness_score"]),
|
||
enrichment_status=str(enrichment["enrichment_status"]),
|
||
enrichment_payload=enrichment.get("enrichment_payload") or {},
|
||
enrichment_error=enrichment.get("enrichment_error"),
|
||
enriched_at=enrichment.get("enriched_at"),
|
||
)
|
||
self.db.add(row)
|
||
await log_operation(
|
||
self.db,
|
||
tenant_id=self.tenant_id,
|
||
user_id=int(user_id),
|
||
project_id=None,
|
||
entity_type="saved_idea",
|
||
entity_id=row.idea_id,
|
||
action="space.saved_idea.create",
|
||
before_data=None,
|
||
after_data={
|
||
"title": row.title,
|
||
"status": row.status,
|
||
"source_type": row.source_type,
|
||
"readiness_score": row.readiness_score,
|
||
"enrichment_status": row.enrichment_status,
|
||
},
|
||
interface_path="/space/saved-ideas",
|
||
caller="user",
|
||
)
|
||
await self._commit()
|
||
await self.db.refresh(row)
|
||
return self._to_read(row)
|
||
|
||
async def update_idea(self, *, idea_id: str, body: SavedIdeaUpdateRequest, user_id: int) -> SavedIdeaRead:
|
||
row = await self._load_owned(idea_id=idea_id, user_id=user_id)
|
||
before = self._snapshot(row)
|
||
if body.raw_text is not None:
|
||
row.raw_text = self._clean_text(body.raw_text)
|
||
if not row.raw_text:
|
||
raise SavedIdeaError("input_empty", "暂存想法内容不能为空", status_code=400)
|
||
enrichment = await self._enrich_or_baseline(row.raw_text)
|
||
row.summary = str(enrichment["summary"])
|
||
row.ai_summary = enrichment.get("ai_summary")
|
||
row.missing_questions = enrichment["missing_questions"]
|
||
row.readiness_score = float(enrichment["readiness_score"])
|
||
row.enrichment_status = str(enrichment["enrichment_status"])
|
||
row.enrichment_payload = enrichment.get("enrichment_payload") or {}
|
||
row.enrichment_error = enrichment.get("enrichment_error")
|
||
row.enriched_at = enrichment.get("enriched_at")
|
||
if row.status in {"saved", "needs_more_info", "draft_ready", "enriched"}:
|
||
row.status = str(enrichment["status"])
|
||
if body.title is not None:
|
||
row.title = self._clean_text(body.title)[:255] or row.title
|
||
if body.summary is not None:
|
||
row.summary = self._clean_text(body.summary) or row.summary
|
||
if body.status is not None:
|
||
row.status = str(body.status)
|
||
if row.status == "archived" and row.archived_at is None:
|
||
row.archived_at = now_cn_naive()
|
||
await log_operation(
|
||
self.db,
|
||
tenant_id=self.tenant_id,
|
||
user_id=int(user_id),
|
||
project_id=None,
|
||
entity_type="saved_idea",
|
||
entity_id=row.idea_id,
|
||
action="space.saved_idea.update",
|
||
before_data=before,
|
||
after_data=self._snapshot(row),
|
||
interface_path=f"/space/saved-ideas/{idea_id}",
|
||
caller="user",
|
||
)
|
||
await self._commit()
|
||
await self.db.refresh(row)
|
||
return self._to_read(row)
|
||
|
||
async def archive_idea(self, *, idea_id: str, user_id: int) -> SavedIdeaRead:
|
||
row = await self._load_owned(idea_id=idea_id, user_id=user_id)
|
||
before = self._snapshot(row)
|
||
row.status = "archived"
|
||
row.archived_at = now_cn_naive()
|
||
await log_operation(
|
||
self.db,
|
||
tenant_id=self.tenant_id,
|
||
user_id=int(user_id),
|
||
project_id=None,
|
||
entity_type="saved_idea",
|
||
entity_id=row.idea_id,
|
||
action="space.saved_idea.archive",
|
||
before_data=before,
|
||
after_data=self._snapshot(row),
|
||
interface_path=f"/space/saved-ideas/{idea_id}/archive",
|
||
caller="user",
|
||
)
|
||
await self._commit()
|
||
await self.db.refresh(row)
|
||
return self._to_read(row)
|
||
|
||
async def promote_to_launch_draft(self, *, idea_id: str, user_id: int) -> tuple[SavedIdeaRead, dict[str, Any]]:
|
||
row = await self._load_owned(idea_id=idea_id, user_id=user_id)
|
||
if row.status == "archived":
|
||
raise SavedIdeaError("idea_archived", "已归档想法不能生成项目草案", status_code=409)
|
||
if row.launch_draft_id:
|
||
row.status = "promoted"
|
||
await self._commit()
|
||
raise SavedIdeaError("draft_already_created", "该想法已经生成过项目草案", status_code=409)
|
||
|
||
launch_service = PMFlowLaunchService(self.db, tenant_id=self.tenant_id)
|
||
draft = await launch_service.create_draft(
|
||
body=PMFlowLaunchDraftRequest(
|
||
input=row.raw_text,
|
||
input_type="template" if row.input_mode == "template" else "text",
|
||
attachments=[PMFlowLaunchAttachment(**item) for item in self._safe_list(row.attachments)],
|
||
source="saved_idea",
|
||
),
|
||
user_id=int(user_id),
|
||
)
|
||
row.status = "promoted"
|
||
row.launch_draft_id = draft.draft_id
|
||
row.promoted_at = now_cn_naive()
|
||
await log_operation(
|
||
self.db,
|
||
tenant_id=self.tenant_id,
|
||
user_id=int(user_id),
|
||
project_id=None,
|
||
entity_type="saved_idea",
|
||
entity_id=row.idea_id,
|
||
action="space.saved_idea.promote_to_launch_draft",
|
||
before_data=None,
|
||
after_data={"launch_draft_id": draft.draft_id, "title": draft.title},
|
||
interface_path=f"/space/saved-ideas/{idea_id}/promote",
|
||
caller="user",
|
||
)
|
||
await self._commit()
|
||
await self.db.refresh(row)
|
||
return self._to_read(row), draft.model_dump(mode="json")
|
||
|
||
async def _load_owned(self, *, idea_id: str, user_id: int) -> SavedIdea:
|
||
stmt = select(SavedIdea).where(
|
||
and_(
|
||
SavedIdea.tenant_id == self.tenant_id,
|
||
SavedIdea.user_id == int(user_id),
|
||
SavedIdea.idea_id == str(idea_id),
|
||
)
|
||
)
|
||
row = (await self.db.execute(stmt)).scalar_one_or_none()
|
||
if row is None:
|
||
raise SavedIdeaError("idea_not_found", "暂存想法不存在或无权访问", status_code=404)
|
||
return row
|
||
|
||
async def _commit(self) -> None:
|
||
try:
|
||
await self.db.commit()
|
||
except Exception:
|
||
await self.db.rollback()
|
||
raise
|
||
|
||
def _to_read(self, row: SavedIdea) -> SavedIdeaRead:
|
||
return SavedIdeaRead(
|
||
id=row.idea_id,
|
||
title=row.title,
|
||
raw_text=row.raw_text,
|
||
summary=row.summary,
|
||
source_type=row.source_type,
|
||
source_ref=row.source_ref,
|
||
input_mode=row.input_mode,
|
||
attachments=self._safe_list(row.attachments),
|
||
status=row.status,
|
||
status_label=STATUS_LABELS.get(row.status, "未整理"),
|
||
ai_summary=row.ai_summary,
|
||
missing_questions=self._safe_str_list(row.missing_questions),
|
||
readiness_score=float(row.readiness_score or 0.0),
|
||
enrichment_status=str(row.enrichment_status or "baseline"),
|
||
enrichment_payload=self._safe_dict(row.enrichment_payload),
|
||
enrichment_error=row.enrichment_error,
|
||
enriched_at=row.enriched_at.isoformat() if row.enriched_at else None,
|
||
launch_draft_id=row.launch_draft_id,
|
||
promoted_project_id=int(row.promoted_project_id) if row.promoted_project_id is not None else None,
|
||
created_at=row.created_at.isoformat() if row.created_at else "",
|
||
updated_at=row.updated_at.isoformat() if row.updated_at else "",
|
||
href=f"/project-launch?ideaId={row.idea_id}",
|
||
)
|
||
|
||
def _snapshot(self, row: SavedIdea) -> dict[str, Any]:
|
||
return {
|
||
"idea_id": row.idea_id,
|
||
"title": row.title,
|
||
"status": row.status,
|
||
"readiness_score": row.readiness_score,
|
||
"enrichment_status": row.enrichment_status,
|
||
"launch_draft_id": row.launch_draft_id,
|
||
}
|
||
|
||
@staticmethod
|
||
def _clean_text(value: str) -> str:
|
||
return re.sub(r"\s+", " ", str(value or "").strip())
|
||
|
||
def _derive_title(self, raw_text: str) -> str:
|
||
text = self._clean_text(raw_text)
|
||
separators = ["。", "!", "?", "\n", ".", "!", "?"]
|
||
end = min([idx for idx in (text.find(s) for s in separators) if idx >= 0] or [len(text)])
|
||
title = text[:end].strip() or text
|
||
return title[:32] if len(title) > 32 else title
|
||
|
||
def _derive_summary(self, raw_text: str) -> str:
|
||
text = self._clean_text(raw_text)
|
||
return text[:120] + ("..." if len(text) > 120 else "")
|
||
|
||
async def _enrich_or_baseline(self, raw_text: str) -> dict[str, Any]:
|
||
baseline = self._baseline_enrichment(raw_text)
|
||
prompt_result = await self._render_enrichment_prompt(raw_text=raw_text)
|
||
try:
|
||
structured = await llm_service.generate_structured_object(
|
||
prompt_result.prompt,
|
||
db=self.db,
|
||
tenant_id=self.tenant_id,
|
||
function_name="space_saved_idea_enrichment_v1",
|
||
temperature=0.0,
|
||
max_tokens=900,
|
||
response_mode="fast",
|
||
routing_meta={
|
||
"scene": "space_saved_idea_enrichment",
|
||
"tier_alias": "low",
|
||
"response_contract": "structured",
|
||
"prompt_runtime_tenant_id": PLATFORM_RUNTIME_TENANT_ID,
|
||
**prompt_result.routing_meta(),
|
||
},
|
||
)
|
||
except Exception as exc:
|
||
baseline["enrichment_status"] = "ai_failed"
|
||
baseline["enrichment_error"] = self._clean_text(str(exc))[:500] or "AI enrichment failed"
|
||
return baseline
|
||
|
||
payload = structured.payload if structured is not None and isinstance(structured.payload, dict) else {}
|
||
return self._normalize_ai_enrichment(raw_text, payload, baseline)
|
||
|
||
async def _render_enrichment_prompt(self, *, raw_text: str) -> PromptRuntimeRenderResult:
|
||
return await PromptRuntimeService(self.db).render(
|
||
tenant_id=PLATFORM_RUNTIME_TENANT_ID,
|
||
prompt_key=self.ENRICHMENT_PROMPT_KEY,
|
||
fallback_template=PROMPT_SAVED_IDEA_ENRICHMENT,
|
||
raw_text=raw_text[:4000],
|
||
)
|
||
|
||
def _baseline_enrichment(self, raw_text: str) -> dict[str, Any]:
|
||
summary = self._derive_summary(raw_text)
|
||
missing_questions = self._missing_questions(raw_text)
|
||
readiness_score = self._readiness_score(raw_text)
|
||
return {
|
||
"summary": summary,
|
||
"status": self._initial_status(raw_text),
|
||
"ai_summary": None,
|
||
"missing_questions": missing_questions,
|
||
"readiness_score": readiness_score,
|
||
"enrichment_status": "baseline",
|
||
"enrichment_payload": {
|
||
"source": "system_baseline",
|
||
"summary": summary,
|
||
"missing_questions": missing_questions,
|
||
"readiness_score": readiness_score,
|
||
},
|
||
"enrichment_error": None,
|
||
"enriched_at": None,
|
||
}
|
||
|
||
def _normalize_ai_enrichment(self, raw_text: str, payload: dict[str, Any], baseline: dict[str, Any]) -> dict[str, Any]:
|
||
summary = self._clean_text(str(payload.get("summary") or "")) or str(baseline["summary"])
|
||
missing_questions = self._safe_str_list(payload.get("missing_questions"))[:3] or list(baseline["missing_questions"])
|
||
score = self._clamp_score(payload.get("readiness_score"), fallback=float(baseline["readiness_score"]))
|
||
raw_status = str(payload.get("status") or "").strip()
|
||
status = raw_status if raw_status in {"needs_more_info", "enriched", "draft_ready"} else self._status_from_score(score)
|
||
confidence = self._clamp_score(payload.get("confidence"), fallback=0.0)
|
||
enrichment_payload = {
|
||
"source": "ai_structured",
|
||
"summary": summary,
|
||
"target_user": self._optional_text(payload.get("target_user")),
|
||
"problem": self._optional_text(payload.get("problem")),
|
||
"value_hypothesis": self._optional_text(payload.get("value_hypothesis")),
|
||
"validation_signal": self._optional_text(payload.get("validation_signal")),
|
||
"constraints": self._safe_str_list(payload.get("constraints"))[:5],
|
||
"missing_questions": missing_questions,
|
||
"readiness_score": score,
|
||
"status": status,
|
||
"confidence": confidence,
|
||
"input_excerpt": self._derive_summary(raw_text),
|
||
}
|
||
return {
|
||
"summary": summary,
|
||
"status": status,
|
||
"ai_summary": summary,
|
||
"missing_questions": missing_questions,
|
||
"readiness_score": score,
|
||
"enrichment_status": "ai_enriched",
|
||
"enrichment_payload": enrichment_payload,
|
||
"enrichment_error": None,
|
||
"enriched_at": now_cn_naive(),
|
||
}
|
||
|
||
def _missing_questions(self, raw_text: str) -> list[str]:
|
||
text = raw_text.lower()
|
||
questions: list[str] = []
|
||
if not any(keyword in text for keyword in ["面向", "用户", "客户", "人群", "who", "user"]):
|
||
questions.append("目标用户是谁?")
|
||
if not any(keyword in text for keyword in ["解决", "问题", "痛点", "提升", "减少", "problem"]):
|
||
questions.append("要解决的核心问题是什么?")
|
||
if not any(keyword in text for keyword in ["成功", "指标", "验证", "收入", "留存", "roi", "metric"]):
|
||
questions.append("什么信号代表这个想法值得推进?")
|
||
return questions[:3]
|
||
|
||
def _readiness_score(self, raw_text: str) -> float:
|
||
length_score = min(len(raw_text) / 400, 0.45)
|
||
completeness = (3 - len(self._missing_questions(raw_text))) * 0.18
|
||
return round(min(0.99, 0.15 + length_score + completeness), 2)
|
||
|
||
def _initial_status(self, raw_text: str) -> str:
|
||
score = self._readiness_score(raw_text)
|
||
return self._status_from_score(score)
|
||
|
||
def _status_from_score(self, score: float) -> str:
|
||
if score >= 0.78:
|
||
return "draft_ready"
|
||
if score >= 0.55:
|
||
return "enriched"
|
||
return "needs_more_info"
|
||
|
||
@staticmethod
|
||
def _safe_list(value: Any) -> list[dict[str, Any]]:
|
||
return value if isinstance(value, list) else []
|
||
|
||
@staticmethod
|
||
def _safe_str_list(value: Any) -> list[str]:
|
||
if not isinstance(value, list):
|
||
return []
|
||
return [str(item).strip()[:500] for item in value if str(item).strip()]
|
||
|
||
@staticmethod
|
||
def _safe_dict(value: Any) -> dict[str, Any]:
|
||
return value if isinstance(value, dict) else {}
|
||
|
||
@staticmethod
|
||
def _optional_text(value: Any) -> str | None:
|
||
text = str(value or "").strip()
|
||
return text[:500] if text and text.lower() != "null" else None
|
||
|
||
@staticmethod
|
||
def _clamp_score(value: Any, *, fallback: float) -> float:
|
||
try:
|
||
score = float(value)
|
||
except (TypeError, ValueError):
|
||
score = float(fallback)
|
||
return round(max(0.0, min(1.0, score)), 2)
|
||
|
||
|
||
__all__ = ["SavedIdeaError", "SpaceSavedIdeaService"]
|