372 lines
19 KiB
Python
372 lines
19 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
from contextlib import asynccontextmanager
|
|
from html import escape
|
|
from pathlib import Path
|
|
from typing import Annotated, Literal
|
|
from urllib.parse import quote, urlsplit
|
|
from uuid import uuid4
|
|
|
|
from fastapi import Depends, FastAPI, Request, Response
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
|
|
from .auth import Auth
|
|
from .db import StoreError, configured_store
|
|
from .learning import LearningService
|
|
from .learning_contracts import Action, EmptyExpectation, NonemptyExpectation
|
|
from .discovery import BusinessDiscoveryInput, DiscoveryInput, DiscoveryService
|
|
from .reading import NoteBinding, ReadingInput, ReadingService
|
|
from .routes import RouteResolveInput, RouteService, RouteVerifyInput
|
|
from .analysis import AnalysisInput, AnalysisService
|
|
from .analysis_reviews import AnalysisReviewService, ReviewInput
|
|
from .business_briefs import BusinessBriefConfirmInput, BusinessBriefInput, BusinessBriefService
|
|
from .worker import BrowserWorker
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
class Input(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
|
|
class Credentials(Input):
|
|
username: str = Field(min_length=1, max_length=64)
|
|
password: str = Field(min_length=1, max_length=256)
|
|
|
|
|
|
class NewTask(Input):
|
|
name: str = Field(min_length=1, max_length=120)
|
|
platform: Literal["demo", "xiaohongshu", "douyin", "other"]
|
|
entry_url: str = Field(min_length=1, max_length=2000)
|
|
goal: str = Field(default="", max_length=4000)
|
|
account_key: str = Field(min_length=2, max_length=80)
|
|
parameters: dict[str, str] = Field(default_factory=dict)
|
|
execution_profile: Literal["playwright-xhs-native-ui-v1", "playwright-xhs-navigation-ui-v1"] | None = None
|
|
|
|
|
|
class Ready(Input):
|
|
identity_label: str = Field(min_length=1, max_length=120)
|
|
|
|
|
|
class Capture(Input):
|
|
note: str = Field(default="", max_length=1000)
|
|
|
|
|
|
class Candidate(Input):
|
|
name: str | None = Field(default=None, max_length=120)
|
|
step_ids: list[Annotated[str, Field(min_length=1, max_length=120, pattern=r"^[A-Za-z0-9_-]+$", strict=True)]] | None = Field(default=None, min_length=1, max_length=30)
|
|
|
|
@model_validator(mode="after")
|
|
def selected_steps(self):
|
|
if "step_ids" in self.model_fields_set and self.step_ids is None:
|
|
raise ValueError("step_ids must be omitted or a nonempty unique list")
|
|
if self.step_ids is not None and len(set(self.step_ids)) != len(self.step_ids):
|
|
raise ValueError("step_ids must be unique")
|
|
return self
|
|
|
|
|
|
class Verify(Input):
|
|
parameters: dict[str, str] = Field(default_factory=dict)
|
|
expected_outputs: dict[str, str | list[str] | EmptyExpectation | NonemptyExpectation] = Field(default_factory=dict)
|
|
sample_name: str = Field(default="变参试跑", max_length=120)
|
|
mode: Literal["exploration", "runtime"] = "exploration"
|
|
baseline_note: str = Field(default="", max_length=4000)
|
|
observation_window_minutes: int = Field(default=60, ge=1, le=10080, strict=True)
|
|
note_binding: NoteBinding | None = None
|
|
|
|
|
|
class QualityObservation(Input):
|
|
state: Literal["waiting", "clear", "alert", "uncertain"]
|
|
note: str = Field(min_length=1, max_length=4000)
|
|
process_review: Literal["passed", "failed", "not_reviewed"] = "not_reviewed"
|
|
source: Literal["official_message", "account_function", "manual"] = "manual"
|
|
notice_ref: str | None = Field(default=None, max_length=500)
|
|
occurred_at: str | None = Field(default=None, max_length=80)
|
|
|
|
|
|
def create_app(home: Path | None = None, worker=None, *, bootstrap_admin: bool = True,
|
|
database_url: str | None = None, database_schema: str | None = None) -> FastAPI:
|
|
home = (home or Path(os.environ.get("ASSISTANT_LEARNING_HOME", ROOT / ".runtime" / "learning"))).resolve()
|
|
store = configured_store(home, database_url, schema=database_schema)
|
|
auth = Auth(store)
|
|
if bootstrap_admin:
|
|
auth.bootstrap_admin()
|
|
browser = worker or BrowserWorker(ROOT, home)
|
|
learning = LearningService(store, browser)
|
|
discovery = DiscoveryService(learning)
|
|
reading = ReadingService(learning, discovery)
|
|
routes = RouteService(learning)
|
|
business_briefs = BusinessBriefService(store)
|
|
analysis = AnalysisService(reading, business_briefs=business_briefs)
|
|
analysis_reviews = AnalysisReviewService(analysis)
|
|
instance_id = os.environ.get("ASSISTANT_INSTANCE_ID") or uuid4().hex
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(application):
|
|
yield
|
|
await browser.close()
|
|
|
|
app = FastAPI(title="Assistant 学习维护后台", version="0.1.0", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
|
|
app.state.store, app.state.auth, app.state.learning, app.state.browser = store, auth, learning, browser
|
|
app.state.discovery = discovery
|
|
app.state.reading = reading
|
|
app.state.routes = routes
|
|
app.state.analysis = analysis
|
|
app.state.analysis_reviews = analysis_reviews
|
|
app.state.business_briefs = business_briefs
|
|
extra_origins = {value.strip().rstrip("/") for value in os.environ.get("ASSISTANT_ALLOWED_ORIGINS", "").split(",") if value.strip()}
|
|
extra_origins.update({"http://127.0.0.1:5179", "http://localhost:5179"})
|
|
|
|
@app.middleware("http")
|
|
async def local_boundary(request: Request, call_next):
|
|
try:
|
|
host = urlsplit("http://" + request.headers.get("host", "")).hostname
|
|
except ValueError:
|
|
host = None
|
|
if host not in {"127.0.0.1", "localhost", "::1"}:
|
|
return JSONResponse({"detail": "学习维护后台仅接受本机访问"}, status_code=400)
|
|
origin = request.headers.get("origin")
|
|
own_origin = str(request.base_url).rstrip("/")
|
|
if origin and origin.rstrip("/") not in extra_origins | {own_origin}:
|
|
return JSONResponse({"detail": "请求来源不在维护后台允许范围内"}, status_code=403)
|
|
response = await call_next(request)
|
|
if request.url.path.startswith("/api/"):
|
|
response.headers["Cache-Control"] = "no-store"
|
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
response.headers["Referrer-Policy"] = "same-origin"
|
|
return response
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
async def validation_error(request, error):
|
|
fields = ", ".join(".".join(str(part) for part in item["loc"] if part != "body") for item in error.errors())
|
|
return JSONResponse({"detail": "输入格式不正确,请检查:" + fields}, status_code=422)
|
|
|
|
@app.exception_handler(StoreError)
|
|
async def database_error(request, error: StoreError):
|
|
return JSONResponse({"detail": {"code": error.code, "message": error.message}}, status_code=error.status)
|
|
|
|
def operator(request: Request) -> str:
|
|
return auth.operator(request, mutate=request.method not in {"GET", "HEAD", "OPTIONS"})
|
|
|
|
@app.get("/api/health")
|
|
def health():
|
|
return {"status": "ok", "service": "assistant-learning", "version": "0.1.0", "mode": "local_maintainer", "model_connected": False, "model_configured": analysis.client.config.configured, "instance_id": instance_id, "database_backend": store.backend}
|
|
|
|
@app.get("/api/analysis/config")
|
|
def analysis_config(owner: str = Depends(operator)):
|
|
return analysis.client.config.public()
|
|
|
|
@app.get("/api/business/briefs")
|
|
def business_brief_list(owner: str = Depends(operator)):
|
|
items = business_briefs.list(owner)
|
|
return {"items": items, "total": len(items)}
|
|
|
|
@app.post("/api/business/briefs", status_code=201)
|
|
async def create_business_brief(body: BusinessBriefInput, owner: str = Depends(operator)):
|
|
return await business_briefs.create(body.request_text, owner)
|
|
|
|
@app.get("/api/business/briefs/{brief_id}")
|
|
def business_brief(brief_id: str, owner: str = Depends(operator)):
|
|
return business_briefs.get(brief_id, owner)
|
|
|
|
@app.post("/api/business/briefs/{brief_id}/confirm", status_code=201)
|
|
def confirm_business_brief(brief_id: str, body: BusinessBriefConfirmInput, owner: str = Depends(operator)):
|
|
return business_briefs.confirm(brief_id, body, owner)
|
|
|
|
@app.post("/api/business/briefs/{brief_id}/discover", status_code=201)
|
|
async def discover_business_brief(brief_id: str, body: BusinessDiscoveryInput, owner: str = Depends(operator)):
|
|
confirmed = business_briefs.get_confirmed(brief_id, owner)
|
|
return await discovery.create_for_brief(owner, confirmed, body.model_dump())
|
|
|
|
@app.get("/api/discovery/readings/{reading_id}/analyses")
|
|
def analysis_runs(reading_id: str, owner: str = Depends(operator)):
|
|
items = analysis.list(reading_id, owner)
|
|
return {"items": items, "total": len(items)}
|
|
|
|
@app.post("/api/discovery/readings/{reading_id}/analyses", status_code=201)
|
|
async def create_analysis_run(reading_id: str, body: AnalysisInput, owner: str = Depends(operator)):
|
|
return await analysis.create(reading_id, owner, body.business_brief_id)
|
|
|
|
@app.get("/api/discovery/analyses/{analysis_id}")
|
|
def analysis_run(analysis_id: str, owner: str = Depends(operator)):
|
|
return analysis.get(analysis_id, owner)
|
|
|
|
@app.get("/api/discovery/analyses/{analysis_id}/reviews")
|
|
def analysis_review_list(analysis_id: str, owner: str = Depends(operator)):
|
|
items = analysis_reviews.list(analysis_id, owner)
|
|
return {"items": items, "total": len(items)}
|
|
|
|
@app.post("/api/discovery/analyses/{analysis_id}/reviews", status_code=201)
|
|
def create_analysis_review(analysis_id: str, body: ReviewInput, owner: str = Depends(operator)):
|
|
return analysis_reviews.create(analysis_id, body, owner)
|
|
|
|
@app.get("/api/discovery/analyses/{analysis_id}/review-summary")
|
|
def analysis_review_summary(analysis_id: str, owner: str = Depends(operator)):
|
|
return analysis_reviews.summary(analysis_id, owner)
|
|
|
|
@app.get("/api/discovery/reviews/{review_id}")
|
|
def analysis_review(review_id: str, owner: str = Depends(operator)):
|
|
return analysis_reviews.get(review_id, owner)
|
|
|
|
@app.get("/api/discovery/runs")
|
|
def discovery_runs(owner: str = Depends(operator)):
|
|
items = discovery.list(owner)
|
|
return {"items": items, "total": len(items)}
|
|
|
|
@app.get("/api/discovery/runs/{run_id}")
|
|
def discovery_run(run_id: str, owner: str = Depends(operator)):
|
|
return discovery.get(run_id, owner)
|
|
|
|
@app.post("/api/discovery/runs", status_code=201)
|
|
async def create_discovery_run(body: DiscoveryInput, owner: str = Depends(operator)):
|
|
return await discovery.create(owner, body.model_dump())
|
|
|
|
@app.get("/api/discovery/runs/{run_id}/readings")
|
|
def reading_runs(run_id: str, owner: str = Depends(operator)):
|
|
items = reading.list(run_id, owner)
|
|
return {"items": items, "total": len(items)}
|
|
|
|
@app.post("/api/discovery/runs/{run_id}/readings", status_code=201)
|
|
async def create_reading_run(run_id: str, body: ReadingInput, owner: str = Depends(operator)):
|
|
return await reading.create(run_id, owner, body.model_dump())
|
|
|
|
@app.get("/api/discovery/readings/{reading_id}")
|
|
def reading_run(reading_id: str, owner: str = Depends(operator)):
|
|
return reading.get(reading_id, owner)
|
|
|
|
@app.get("/api/auth/status")
|
|
def auth_status(request: Request, response: Response):
|
|
return auth.status(request, response)
|
|
|
|
@app.post("/api/auth/setup")
|
|
def setup(body: Credentials, request: Request, response: Response):
|
|
return auth.setup(request, response, body.username, body.password)
|
|
|
|
@app.post("/api/auth/login")
|
|
def login(body: Credentials, request: Request, response: Response):
|
|
return auth.login(request, response, body.username, body.password)
|
|
|
|
@app.post("/api/auth/logout")
|
|
def logout(request: Request, response: Response):
|
|
return auth.logout(request, response)
|
|
|
|
@app.get("/api/learning/overview")
|
|
def overview(owner: str = Depends(operator)):
|
|
return learning.overview(owner)
|
|
|
|
@app.get("/api/learning/routes")
|
|
def learned_routes(owner: str = Depends(operator)):
|
|
items = routes.catalogue(owner)
|
|
return {"items": items, "total": len(items)}
|
|
|
|
@app.post("/api/learning/routes/resolve")
|
|
def resolve_learned_route(body: RouteResolveInput, owner: str = Depends(operator)):
|
|
return routes.resolve(owner, body.model_dump())
|
|
|
|
@app.post("/api/learning/routes/verify", status_code=201)
|
|
async def verify_learned_route(body: RouteVerifyInput, owner: str = Depends(operator)):
|
|
return await routes.verify(owner, body.model_dump())
|
|
|
|
@app.get("/api/learning/tasks")
|
|
async def tasks(owner: str = Depends(operator)):
|
|
for task_id in list(learning.sessions):
|
|
if store.one("SELECT id FROM learning_tasks WHERE id=$1 AND owner_id=$2", (task_id, owner)):
|
|
await learning.refresh_browser(task_id, owner)
|
|
items = learning.tasks(owner)
|
|
return {"items": items, "total": len(items)}
|
|
|
|
@app.post("/api/learning/tasks", status_code=201)
|
|
def new_task(body: NewTask, owner: str = Depends(operator)):
|
|
return learning.create_task(owner, body.model_dump())
|
|
|
|
@app.get("/api/learning/tasks/{task_id}")
|
|
async def task_detail(task_id: str, owner: str = Depends(operator)):
|
|
await learning.refresh_browser(task_id, owner)
|
|
return learning.detail(task_id, owner)
|
|
|
|
@app.post("/api/learning/tasks/{task_id}/browser/open")
|
|
async def open_browser(task_id: str, owner: str = Depends(operator)):
|
|
return await learning.open(task_id, owner)
|
|
|
|
@app.post("/api/learning/tasks/{task_id}/browser/ready")
|
|
async def ready_browser(task_id: str, body: Ready, owner: str = Depends(operator)):
|
|
return await learning.ready(task_id, owner, body.identity_label)
|
|
|
|
@app.post("/api/learning/tasks/{task_id}/browser/capture")
|
|
async def capture_browser(task_id: str, body: Capture | None = None, owner: str = Depends(operator)):
|
|
return await learning.capture(task_id, owner, body.note if body else "")
|
|
|
|
@app.post("/api/learning/tasks/{task_id}/browser/action")
|
|
async def action_browser(task_id: str, body: Action, owner: str = Depends(operator)):
|
|
return await learning.action(task_id, owner, body.model_dump(exclude_none=True))
|
|
|
|
@app.post("/api/learning/tasks/{task_id}/browser/close")
|
|
async def close_browser(task_id: str, owner: str = Depends(operator)):
|
|
return await learning.close(task_id, owner)
|
|
|
|
@app.post("/api/learning/tasks/{task_id}/candidates", status_code=201)
|
|
def new_candidate(task_id: str, body: Candidate | None = None, owner: str = Depends(operator)):
|
|
return learning.create_candidate(task_id, owner, body.name if body else None, step_ids=body.step_ids if body else None)
|
|
|
|
@app.get("/api/learning/candidates")
|
|
def candidates(owner: str = Depends(operator)):
|
|
items = learning.candidates(owner)
|
|
return {"items": items, "total": len(items)}
|
|
|
|
@app.get("/api/learning/candidates/{candidate_id}")
|
|
def candidate_detail(candidate_id: str, owner: str = Depends(operator)):
|
|
return {**learning.candidate(candidate_id, owner), "validations": learning.validations(owner, candidate_id=candidate_id)}
|
|
|
|
@app.post("/api/learning/candidates/{candidate_id}/check")
|
|
def check_candidate(candidate_id: str, owner: str = Depends(operator)):
|
|
return learning.check(candidate_id, owner)
|
|
|
|
@app.post("/api/learning/candidates/{candidate_id}/verify")
|
|
async def verify_candidate(candidate_id: str, body: Verify, owner: str = Depends(operator)):
|
|
return await learning.verify(candidate_id, owner, body.model_dump())
|
|
|
|
@app.post("/api/learning/candidates/{candidate_id}/publish-check")
|
|
def publish_check(candidate_id: str, owner: str = Depends(operator)):
|
|
return learning.publish_check(candidate_id, owner)
|
|
|
|
@app.get("/api/learning/validations")
|
|
def validations(owner: str = Depends(operator)):
|
|
items = learning.validations(owner)
|
|
return {"items": items, "total": len(items)}
|
|
|
|
@app.post("/api/learning/validations/{validation_id}/quality-observations", status_code=201)
|
|
def append_quality_observation(validation_id: str, body: QualityObservation, owner: str = Depends(operator)):
|
|
return learning.append_quality_observation(validation_id, owner, body.model_dump(exclude_none=True))
|
|
|
|
@app.get("/api/learning/artifacts/{artifact_id}")
|
|
def artifact(artifact_id: str, owner: str = Depends(operator)):
|
|
return FileResponse(learning.artifact(artifact_id, owner), media_type="image/png")
|
|
|
|
# Fixture pages intentionally contain synthetic public content, no operator or platform data.
|
|
@app.get("/api/learning/demo-page", response_class=HTMLResponse)
|
|
def demo_page(q: str = ""):
|
|
term = q.strip()[:120] or "建材"
|
|
safe = escape(term, quote=True)
|
|
cards = ""
|
|
for i in range(1, 4):
|
|
detail_attr = 'data-testid="detail-link"' if i == 1 else ""
|
|
cards += f'<article data-testid="result"><h2>{safe}采购需求 {i}</h2><p>本机演示内容,用于验证关键词与读取对象是否对应。</p><a {detail_attr} href="/api/learning/demo-detail/{i}?q={quote(term)}">查看详情 {i}</a></article>'
|
|
return HTMLResponse(f'<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>本机链路验证页面</title><style>body{{font:16px/1.6 system-ui,sans-serif;max-width:860px;margin:50px auto;padding:0 24px;color:#202b35;background:#f6f7f8}}h1{{font-size:25px}}form{{display:flex;gap:10px;margin:24px 0}}input,button{{font:inherit;padding:9px 12px}}article{{padding:18px;background:white;border:1px solid #dce2e6;margin:14px 0;border-radius:8px}}h2{{font-size:18px;margin:0}}a{{color:#14695e}}</style><h1>本机搜索链路</h1><p>这是本项目的演示页面,不连接小红书或抖音,也不产生真实客户线索。</p><form method="get"><input data-testid="search-input" name="q" aria-label="搜索关键词" value="{escape(q,quote=True)}"><button data-testid="search-button" type="submit">搜索</button></form><main>{cards}</main></html>')
|
|
|
|
@app.get("/api/learning/demo-detail/{item}", response_class=HTMLResponse)
|
|
def demo_detail(item: int, q: str = "建材"):
|
|
safe = escape(q[:120], quote=True)
|
|
return HTMLResponse(f'<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>演示详情与评论</title><style>body{{font:16px/1.7 system-ui;max-width:850px;margin:50px auto;padding:0 24px}}article{{padding:16px;border-bottom:1px solid #ddd}}</style><h1>{safe}采购需求 {item}</h1><p>本机演示作者,不是真实平台账号。</p><section><h2>评论</h2><article data-testid="comment">演示评论甲:正在了解{safe},需要比较规格。</article><article data-testid="comment">演示评论乙:{safe}适用场景有哪些?</article></section><a href="/api/learning/demo-page?q={quote(q[:120])}">返回搜索</a></html>')
|
|
|
|
@app.post("/api/learning/demo")
|
|
async def run_demo(request: Request, owner: str = Depends(operator)):
|
|
return await learning.demo(owner, str(request.base_url))
|
|
|
|
return app
|
|
|
|
|
|
app = create_app()
|