{safe}采购需求 {i}
本机演示内容,用于验证关键词与读取对象是否对应。
查看详情 {i}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' 本机演示内容,用于验证关键词与读取对象是否对应。{safe}采购需求 {i}
这是本项目的演示页面,不连接小红书或抖音,也不产生真实客户线索。
本机演示作者,不是真实平台账号。