diff --git a/.gitignore b/.gitignore index e2c5572..27d7f1c 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,10 @@ backend/.venv/ venv/ # Backend runtime data +palm_reading.db +*.db +*.sqlite +*.sqlite3 backend/palm_reading.db backend/*.db backend/*.sqlite diff --git a/backend/.env.example b/backend/.env.example index 52b0683..371117b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,6 +8,7 @@ OPENAI_API_KEY= OPENAI_BASE_URL= OPENAI_MODEL=gpt-4.1-mini OPENAI_IMAGE_MODEL=gpt-image-2 +OPENAI_TIMEOUT_SECONDS=180 SHARE_IMAGE_MODE=ai WECHAT_APP_ID= diff --git a/backend/app/api/v1/endpoints/readings.py b/backend/app/api/v1/endpoints/readings.py new file mode 100644 index 0000000..0015493 --- /dev/null +++ b/backend/app/api/v1/endpoints/readings.py @@ -0,0 +1,102 @@ +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status +from pydantic import ValidationError +from sqlalchemy import desc, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import AsyncSessionLocal, get_db +from app.core.security import get_current_user +from app.models.reading import Reading +from app.models.uploaded_image import UploadedImage +from app.models.user import User +from app.schemas.reading import BaziInput, ReadingCreate, ReadingDetail, ReadingSummary +from app.services.image_service import ImageService +from app.services.reading_service import ReadingService + +router = APIRouter() + + +async def generate_reading_task(reading_id: str) -> None: + async with AsyncSessionLocal() as session: + await ReadingService().generate(session, reading_id) + + +@router.post("", response_model=ReadingDetail, status_code=status.HTTP_201_CREATED) +async def create_reading( + payload: ReadingCreate, + background_tasks: BackgroundTasks, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + input_data = dict(payload.input_data) + image_id = payload.image_id + + if payload.reading_type in {"palm", "face"}: + image_result = await db.execute(select(UploadedImage).where(UploadedImage.id == image_id, UploadedImage.user_id == user.id)) + image = image_result.scalar_one_or_none() + if image is None: + raise HTTPException(status_code=404, detail="Image not found") + elif payload.reading_type == "bazi": + try: + input_data = BaziInput.model_validate(input_data).model_dump() + except ValidationError as exc: + raise HTTPException(status_code=422, detail=exc.errors()) from exc + + reading = Reading( + user_id=user.id, + reading_type=payload.reading_type, + image_id=image_id, + input_data=input_data, + status="pending", + ) + db.add(reading) + await db.flush() + await db.refresh(reading) + await db.commit() + background_tasks.add_task(generate_reading_task, reading.id) + return reading + + +@router.get("", response_model=list[ReadingSummary]) +async def list_readings(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + result = await db.execute( + select(Reading).where(Reading.user_id == user.id).order_by(desc(Reading.created_at)).limit(80) + ) + readings = result.scalars().all() + return [ + ReadingSummary( + id=reading.id, + reading_type=reading.reading_type, + status=reading.status, + created_at=reading.created_at, + overall_summary=(reading.report_data or {}).get("overall_summary") if reading.report_data else None, + ) + for reading in readings + ] + + +@router.get("/{reading_id}", response_model=ReadingDetail) +async def get_reading(reading_id: str, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + return await _get_owned_reading(db, reading_id, user.id) + + +@router.delete("/{reading_id}") +async def delete_reading(reading_id: str, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + reading = await _get_owned_reading(db, reading_id, user.id) + image = None + if reading.image_id: + image_result = await db.execute(select(UploadedImage).where(UploadedImage.id == reading.image_id)) + image = image_result.scalar_one_or_none() + await db.delete(reading) + await db.flush() + if image: + ImageService().delete(image.storage_key) + await db.delete(image) + return {"status": "deleted"} + + +async def _get_owned_reading(db: AsyncSession, reading_id: str, user_id: str) -> Reading: + result = await db.execute(select(Reading).where(Reading.id == reading_id, Reading.user_id == user_id)) + reading = result.scalar_one_or_none() + if reading is None: + raise HTTPException(status_code=404, detail="Reading not found") + return reading diff --git a/backend/app/api/v1/endpoints/reports.py b/backend/app/api/v1/endpoints/reports.py index bc1427f..94e656e 100644 --- a/backend/app/api/v1/endpoints/reports.py +++ b/backend/app/api/v1/endpoints/reports.py @@ -70,6 +70,7 @@ async def create_report( db.add(report) await db.flush() await db.refresh(report) + await db.commit() background_tasks.add_task(generate_report_task, report.id) return report @@ -137,6 +138,7 @@ async def create_share_image_job( db.add(job) await db.flush() await db.refresh(job) + await db.commit() background_tasks.add_task(generate_share_image_task, job.id) return job diff --git a/backend/app/api/v1/endpoints/uploads.py b/backend/app/api/v1/endpoints/uploads.py index ed53886..39955a3 100644 --- a/backend/app/api/v1/endpoints/uploads.py +++ b/backend/app/api/v1/endpoints/uploads.py @@ -17,11 +17,24 @@ async def upload_palm_image( user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): + return await _store_image(file, user, db, "palm") + + +@router.post("/face", response_model=UploadResponse) +async def upload_face_image( + file: UploadFile = File(...), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + return await _store_image(file, user, db, "face") + + +async def _store_image(file: UploadFile, user: User, db: AsyncSession, prefix: str) -> UploadResponse: storage_key, size_bytes, expires_at, quality_check = await ImageService().validate_and_store(file, user.id) image = UploadedImage( user_id=user.id, storage_key=storage_key, - original_filename=file.filename or "palm", + original_filename=file.filename or prefix, content_type=file.content_type or "application/octet-stream", size_bytes=size_bytes, expires_at=expires_at, diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index afdc9a8..b86d489 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,8 +1,9 @@ from fastapi import APIRouter -from app.api.v1.endpoints import auth, reports, uploads +from app.api.v1.endpoints import auth, readings, reports, uploads api_router = APIRouter() api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(uploads.router, prefix="/uploads", tags=["uploads"]) api_router.include_router(reports.router, prefix="/reports", tags=["reports"]) +api_router.include_router(readings.router, prefix="/readings", tags=["readings"]) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 37ad11e..513fac7 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -16,6 +16,7 @@ class Settings(BaseSettings): openai_base_url: str | None = None openai_model: str = "gpt-4.1-mini" openai_image_model: str = "gpt-image-2" + openai_timeout_seconds: float = 180 share_image_mode: str = "ai" wechat_app_id: str | None = None diff --git a/backend/app/core/database.py b/backend/app/core/database.py index c4b4dd2..1a54f01 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -25,7 +25,7 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]: async def init_db() -> None: - from app.models import palm_report, share_image_job, uploaded_image, user # noqa: F401 + from app.models import palm_report, reading, share_image_job, uploaded_image, user # noqa: F401 async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) diff --git a/backend/app/models/reading.py b/backend/app/models/reading.py new file mode 100644 index 0000000..94a51a0 --- /dev/null +++ b/backend/app/models/reading.py @@ -0,0 +1,25 @@ +from datetime import datetime +from uuid import uuid4 + +from sqlalchemy import DateTime, ForeignKey, JSON, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class Reading(Base): + __tablename__ = "readings" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), index=True) + reading_type: Mapped[str] = mapped_column(String(24), index=True) + image_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("uploaded_images.id"), nullable=True) + input_data: Mapped[dict] = mapped_column(JSON, default=dict) + status: Mapped[str] = mapped_column(String(24), default="pending", index=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + report_data: Mapped[dict | None] = mapped_column(JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + user = relationship("User", back_populates="readings") + image = relationship("UploadedImage", back_populates="readings") diff --git a/backend/app/models/uploaded_image.py b/backend/app/models/uploaded_image.py index 66f4b27..b2fd0cd 100644 --- a/backend/app/models/uploaded_image.py +++ b/backend/app/models/uploaded_image.py @@ -20,3 +20,4 @@ class UploadedImage(Base): created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) reports = relationship("PalmReport", back_populates="image") + readings = relationship("Reading", back_populates="image") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 38dbd53..4dd2166 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -17,3 +17,4 @@ class User(Base): updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) reports = relationship("PalmReport", back_populates="user") + readings = relationship("Reading", back_populates="user") diff --git a/backend/app/schemas/reading.py b/backend/app/schemas/reading.py new file mode 100644 index 0000000..3a6050f --- /dev/null +++ b/backend/app/schemas/reading.py @@ -0,0 +1,56 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +ReadingType = Literal["palm", "face", "bazi"] +ReadingStatus = Literal["pending", "processing", "completed", "failed"] + + +class BaziInput(BaseModel): + nickname: str | None = None + gender: str | None = None + calendar_type: Literal["solar", "lunar"] = "solar" + is_leap_month: bool = False + birth_date: str + birth_time: str | None = None + time_unknown: bool = False + birth_place: str | None = None + + +class ReadingCreate(BaseModel): + reading_type: ReadingType + image_id: str | None = None + input_data: dict = Field(default_factory=dict) + + @model_validator(mode="after") + def validate_payload(self): + if self.reading_type in {"palm", "face"} and not self.image_id: + raise ValueError("image_id is required for image readings") + if self.reading_type == "bazi": + BaziInput.model_validate(self.input_data) + return self + + +class ReadingSummary(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + reading_type: str + status: str + created_at: datetime + overall_summary: str | None = None + + +class ReadingDetail(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + reading_type: str + status: str + input_data: dict + error_message: str | None = None + report_data: dict | None = None + created_at: datetime + updated_at: datetime diff --git a/backend/app/services/analyzer_common.py b/backend/app/services/analyzer_common.py new file mode 100644 index 0000000..8b546d7 --- /dev/null +++ b/backend/app/services/analyzer_common.py @@ -0,0 +1,122 @@ +import base64 +import json + +from openai import AsyncOpenAI + +from app.core.config import settings + +DISCLAIMER = "本报告仅用于娱乐占卜与自我反思,不构成医学、心理、职业、财务、投资或任何人生决策建议。" + +SYSTEM_PROMPT = ( + "你是“赛博先生”,一个面向普通人的娱乐型 AI 命理解读助手。" + "你的风格要像一个会聊天、懂生活的朋友:温和、具体、接地气,有一点玄学仪式感,但不要装神秘。" + "所有表达都必须使用“可能、倾向、适合、提醒你”这类非确定性措辞。" + "禁止给出医疗、心理诊断、投资、职业成败、婚恋结果、寿命、灾祸等确定性判断。" + "不要堆砌专业术语,不要写得像教材;每个结论都要落到生活、学习、事业、关系或近期行动里的具体场景。" +) + +REPORT_SCHEMA = { + "name": "reading_report", + "schema": { + "type": "object", + "additionalProperties": False, + "properties": { + "quality_check": { + "type": "object", + "additionalProperties": False, + "properties": { + "can_analyze": {"type": "boolean"}, + "reason": {"type": "string"}, + "confidence": {"type": "number"}, + }, + "required": ["can_analyze", "reason", "confidence"], + }, + "overall_summary": {"type": "string"}, + "dimensions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "properties": { + "name": {"type": "string"}, + "observations": {"type": "array", "items": {"type": "string"}}, + "interpretation": {"type": "string"}, + "confidence": {"type": "number"}, + "advice": {"type": "string"}, + }, + "required": ["name", "observations", "interpretation", "confidence", "advice"], + }, + }, + "strengths": {"type": "array", "items": {"type": "string"}}, + "challenges": {"type": "array", "items": {"type": "string"}}, + "suggestions": {"type": "array", "items": {"type": "string"}}, + "lucky_keywords": {"type": "array", "items": {"type": "string"}}, + "disclaimer": {"type": "string"}, + }, + "required": [ + "quality_check", + "overall_summary", + "dimensions", + "strengths", + "challenges", + "suggestions", + "lucky_keywords", + "disclaimer", + ], + }, + "strict": True, +} + + +class BaseAnalyzer: + def __init__(self) -> None: + self.client = ( + AsyncOpenAI( + api_key=settings.openai_api_key, + base_url=settings.openai_base_url, + timeout=settings.openai_timeout_seconds, + ) + if settings.openai_api_key + else None + ) + + async def _create_text_report(self, user_prompt: str) -> dict: + if not self.client: + raise RuntimeError("OpenAI client is not configured") + response = await self.client.responses.create( + model=settings.openai_model, + input=[ + {"role": "system", "content": [{"type": "input_text", "text": SYSTEM_PROMPT}]}, + {"role": "user", "content": [{"type": "input_text", "text": user_prompt}]}, + ], + text={"format": {"type": "json_schema", **REPORT_SCHEMA}}, + ) + data = json.loads(response.output_text) + data["disclaimer"] = DISCLAIMER + return data + + async def _create_image_report(self, user_prompt: str, image_bytes: bytes, content_type: str) -> dict: + if not self.client: + raise RuntimeError("OpenAI client is not configured") + image_data = base64.b64encode(image_bytes).decode("ascii") + response = await self.client.responses.create( + model=settings.openai_model, + input=[ + {"role": "system", "content": [{"type": "input_text", "text": SYSTEM_PROMPT}]}, + { + "role": "user", + "content": [ + {"type": "input_text", "text": user_prompt}, + { + "type": "input_image", + "image_url": f"data:{content_type};base64,{image_data}", + "detail": "high", + }, + ], + }, + ], + text={"format": {"type": "json_schema", **REPORT_SCHEMA}}, + ) + data = json.loads(response.output_text) + data["disclaimer"] = DISCLAIMER + return data diff --git a/backend/app/services/bazi_analyzer.py b/backend/app/services/bazi_analyzer.py new file mode 100644 index 0000000..3d835ec --- /dev/null +++ b/backend/app/services/bazi_analyzer.py @@ -0,0 +1,91 @@ +import json + +from app.services.analyzer_common import DISCLAIMER, BaseAnalyzer + +USER_PROMPT_TEMPLATE = ( + "请基于下面这份八字排盘结果,生成中文八字娱乐报告。" + "排盘结果由后端计算,不能自行改动四柱:{chart_json}" + "必须覆盖四柱结构、日主与五行、十神关系、学习成长、事业节奏、关系沟通、近期行动。" + "写作要求:" + "1. overall_summary 用 2 到 4 句话,像给用户本人看的开场结论。" + "2. dimensions 中每个 interpretation 必须落到现实场景,例如学习效率、职业节奏、沟通方式、情绪恢复、计划执行。" + "3. 每个 advice 必须是今天或本周能做的小建议。" + "4. 可以少量使用日主、五行、十神等术语,但必须用普通人能听懂的话解释。" + "5. 不预测寿命、灾祸、婚姻成败、财富结果,不做宿命论。" + "6. 如果时辰不详,要诚实降低相关维度 confidence,并说明分析会更偏概览。" +) + + +class BaziAnalyzer(BaseAnalyzer): + async def analyze(self, chart: dict) -> dict: + if not self.client: + return self._mock_report(chart) + prompt = USER_PROMPT_TEMPLATE.format(chart_json=json.dumps(chart, ensure_ascii=False)) + try: + return await self._create_text_report(prompt) + except Exception as exc: + report = self._mock_report(chart) + report["quality_check"] = { + "can_analyze": True, + "reason": f"模型响应较慢,已先基于后端排盘生成本地报告:{exc}", + "confidence": 0.62, + } + report["overall_summary"] = "模型响应较慢,先生先根据后端排出的四柱给你一份基础娱乐解读。等模型接口稳定后,可以再次生成更细腻的版本。" + report["overall_summary"] + return report + + def _mock_report(self, chart: dict) -> dict: + pillars = chart.get("pillars", {}) + day_master = chart.get("day_master", "日主") + return { + "quality_check": {"can_analyze": True, "reason": "mock mode", "confidence": 0.76}, + "overall_summary": f"这是一份基于 {pillars.get('year', '')} {pillars.get('month', '')} {pillars.get('day', '')} {pillars.get('time', '')} 的娱乐八字报告。整体看,你的日主线索偏向“{day_master}”,适合先建立稳定节奏,再去放大自己的表达和行动力。近期最重要的是别急着求大结果,先把可执行的小计划跑起来。", + "dimensions": [ + { + "name": "四柱结构", + "observations": [f"年柱:{pillars.get('year')}", f"月柱:{pillars.get('month')}", f"日柱:{pillars.get('day')}", f"时柱:{pillars.get('time')}"], + "interpretation": "四柱结构象征一个人的底色、节奏和外部互动方式。放到现实里,你更适合把目标拆成阶段,不必要求自己一步到位。", + "confidence": 0.72, + "advice": "今天先写下一个 7 天目标,只保留最关键的三件事。", + }, + { + "name": "日主与五行", + "observations": [f"日主:{day_master}", f"五行线索:{chart.get('wuxing', {})}"], + "interpretation": "日主象征你处理世界的核心方式。你适合找到自己的稳定补给,再去应对学习、工作和关系里的变化。", + "confidence": 0.7, + "advice": "这周给自己固定一个恢复时间,别把精力全部交给外界安排。", + }, + { + "name": "十神关系", + "observations": [f"十神线索:{chart.get('shi_shen', {})}"], + "interpretation": "十神关系可以理解为你和资源、表达、规则、压力之间的互动方式。现实里,适合把压力转成清单,而不是只在脑子里反复想。", + "confidence": 0.66, + "advice": "遇到复杂任务时,先写下“我能控制的部分”。", + }, + { + "name": "学习成长", + "observations": ["适合阶段式吸收", "需要稳定反馈"], + "interpretation": "学习上你更适合循序渐进。比起临时爆发,持续复盘更能帮你看到进步。", + "confidence": 0.68, + "advice": "今天结束前写 5 行复盘,记录一个学到的点。", + }, + { + "name": "事业节奏", + "observations": ["适合沉淀方法", "不宜频繁自我推翻"], + "interpretation": "事业上适合先做稳定交付,再逐步争取主动权。越是焦虑时,越需要用作品和结果给自己定心。", + "confidence": 0.65, + "advice": "本周整理一个能展示的小成果,哪怕只是文档或案例。", + }, + { + "name": "关系沟通", + "observations": ["需要清晰边界", "适合具体表达"], + "interpretation": "关系里你可能会先照顾气氛,但真正有效的是把需求讲清楚。温和不等于什么都自己消化。", + "confidence": 0.63, + "advice": "把一个模糊的不舒服,改写成一句具体请求。", + }, + ], + "strengths": ["适合长期积累", "能在变化里调整节奏", "对关系氛围比较敏感"], + "challenges": ["压力大时容易想太多", "计划过满会消耗行动力"], + "suggestions": ["生活上固定恢复时间", "学习成长上做短复盘", "事业工作上沉淀可展示成果", "关系沟通上把需求说具体"], + "lucky_keywords": ["阶段推进", "稳定补给", "具体表达"], + "disclaimer": DISCLAIMER, + } diff --git a/backend/app/services/bazi_calculator.py b/backend/app/services/bazi_calculator.py new file mode 100644 index 0000000..7779557 --- /dev/null +++ b/backend/app/services/bazi_calculator.py @@ -0,0 +1,98 @@ +from datetime import datetime + +try: + from lunar_python import Lunar, Solar +except ImportError: # pragma: no cover - production image installs the package + Lunar = None + Solar = None + + +class BaziCalculator: + def calculate(self, payload: dict) -> dict: + birth_date = payload["birth_date"] + time_unknown = bool(payload.get("time_unknown")) + birth_time = payload.get("birth_time") if not time_unknown else "12:00" + hour, minute = self._parse_time(birth_time or "12:00") + year, month, day = self._parse_date(birth_date) + calendar_type = payload.get("calendar_type", "solar") + + if Solar and Lunar: + if calendar_type == "lunar": + lunar_month = -month if payload.get("is_leap_month") else month + lunar = Lunar.fromYmdHms(year, lunar_month, day, hour, minute, 0) + solar = lunar.getSolar() + else: + solar = Solar.fromYmdHms(year, month, day, hour, minute, 0) + lunar = solar.getLunar() + eight = lunar.getEightChar() + return { + "calendar_type": calendar_type, + "is_leap_month": bool(payload.get("is_leap_month")), + "birth_date": birth_date, + "birth_time": None if time_unknown else f"{hour:02d}:{minute:02d}", + "time_unknown": time_unknown, + "birth_place": payload.get("birth_place"), + "nickname": payload.get("nickname"), + "gender": payload.get("gender"), + "solar_date": f"{solar.getYear():04d}-{solar.getMonth():02d}-{solar.getDay():02d}", + "lunar_date": f"{lunar.getYear()}年{lunar.getMonth()}月{lunar.getDay()}日", + "pillars": { + "year": eight.getYear(), + "month": eight.getMonth(), + "day": eight.getDay(), + "time": eight.getTime() if not time_unknown else "时辰不详", + }, + "wuxing": { + "year": eight.getYearWuXing(), + "month": eight.getMonthWuXing(), + "day": eight.getDayWuXing(), + "time": eight.getTimeWuXing() if not time_unknown else "时辰不详", + }, + "shi_shen": { + "year": eight.getYearShiShenGan(), + "month": eight.getMonthShiShenGan(), + "day": "日主", + "time": eight.getTimeShiShenGan() if not time_unknown else "时辰不详", + }, + "day_master": eight.getDayGan(), + } + + return self._fallback_chart(payload, year, month, day, hour, minute, time_unknown) + + def _fallback_chart(self, payload: dict, year: int, month: int, day: int, hour: int, minute: int, time_unknown: bool) -> dict: + stems = "甲乙丙丁戊己庚辛壬癸" + branches = "子丑寅卯辰巳午未申酉戌亥" + seed = year * 372 + month * 31 + day + hour + + def pillar(offset: int) -> str: + return stems[(seed + offset) % 10] + branches[(seed + offset) % 12] + + return { + "calendar_type": payload.get("calendar_type", "solar"), + "is_leap_month": bool(payload.get("is_leap_month")), + "birth_date": payload["birth_date"], + "birth_time": None if time_unknown else f"{hour:02d}:{minute:02d}", + "time_unknown": time_unknown, + "birth_place": payload.get("birth_place"), + "nickname": payload.get("nickname"), + "gender": payload.get("gender"), + "solar_date": payload["birth_date"], + "lunar_date": "本地未安装 lunar_python,暂用娱乐排盘", + "pillars": { + "year": pillar(0), + "month": pillar(13), + "day": pillar(27), + "time": pillar(41) if not time_unknown else "时辰不详", + }, + "wuxing": {"year": "参考", "month": "参考", "day": "参考", "time": "参考"}, + "shi_shen": {"year": "参考", "month": "参考", "day": "日主", "time": "参考"}, + "day_master": pillar(27)[0], + } + + def _parse_date(self, value: str) -> tuple[int, int, int]: + parsed = datetime.strptime(value, "%Y-%m-%d") + return parsed.year, parsed.month, parsed.day + + def _parse_time(self, value: str) -> tuple[int, int]: + parsed = datetime.strptime(value, "%H:%M") + return parsed.hour, parsed.minute diff --git a/backend/app/services/face_analyzer.py b/backend/app/services/face_analyzer.py new file mode 100644 index 0000000..4b73686 --- /dev/null +++ b/backend/app/services/face_analyzer.py @@ -0,0 +1,75 @@ +from app.services.analyzer_common import DISCLAIMER, BaseAnalyzer + +USER_PROMPT = ( + "请分析这张单人正脸照片,生成中文面相娱乐报告。" + "必须先判断照片是否适合分析:是否单人、正脸、五官无遮挡、光线清晰。" + "必须覆盖脸型轮廓、三庭五眼、眉眼神态、鼻唇表达、气色状态、整体气质。" + "写作要求:" + "1. overall_summary 用 2 到 4 句话,像给用户本人看的开场结论,要贴近日常生活。" + "2. dimensions 中每个 interpretation 必须关联现实场景,例如学习专注、工作表达、社交沟通、情绪恢复、生活节奏。" + "3. 每个 advice 必须是今天或本周能做的小建议,不要空泛。" + "4. 不评价美丑,不做健康诊断,不判断年龄、种族、身份、财富、婚姻结局。" + "5. 如果照片不够正脸、多人、遮挡或不清晰,要把 quality_check.can_analyze 设为 false,并说明原因。" +) + + +class FaceAnalyzer(BaseAnalyzer): + async def analyze(self, image_bytes: bytes, content_type: str) -> dict: + if not self.client: + return self._mock_report() + return await self._create_image_report(USER_PROMPT, image_bytes, content_type) + + def _mock_report(self) -> dict: + return { + "quality_check": {"can_analyze": True, "reason": "mock mode", "confidence": 0.71}, + "overall_summary": "这是一份娱乐向面相报告。整体看,你给人的第一感觉偏稳,适合用清楚、温和的表达建立信任。最近如果在学习、工作或关系里需要推进事情,先把状态收拾利落,会更容易进入节奏。", + "dimensions": [ + { + "name": "脸型轮廓", + "observations": ["整体轮廓较均衡", "面部线条有稳定感"], + "interpretation": "这类轮廓象征做事方式偏稳,现实里可能更适合长期推进,而不是靠一阵热情冲刺。", + "confidence": 0.68, + "advice": "这周选一个最重要的目标,每天只推进一个小动作。", + }, + { + "name": "三庭五眼", + "observations": ["比例观感较协调", "中庭存在一定表达重心"], + "interpretation": "比例协调象征你在计划和表达之间有平衡感。学习或工作中,适合先梳理框架,再输出观点。", + "confidence": 0.64, + "advice": "开会或沟通前,先写下 3 个要点,避免临场发散。", + }, + { + "name": "眉眼神态", + "observations": ["眉眼状态较集中", "眼神表达偏内敛"], + "interpretation": "眉眼偏集中,象征观察力不错。你可能容易先看懂气氛,再决定怎么表达。", + "confidence": 0.66, + "advice": "有想法时别只观察,试着先说一个小判断。", + }, + { + "name": "鼻唇表达", + "observations": ["表达区域较清晰", "唇部状态偏平和"], + "interpretation": "这象征你适合用稳定语气处理分歧。关系里,不必一次讲完所有委屈,先说最具体的一件事就好。", + "confidence": 0.61, + "advice": "本周沟通需求时,用“我希望下次可以……”开头。", + }, + { + "name": "气色状态", + "observations": ["画面气色受光线影响", "整体状态可读性尚可"], + "interpretation": "气色象征当下状态。照片里呈现出的状态提醒你,近期要少一点硬撑,多一点规律恢复。", + "confidence": 0.55, + "advice": "今晚把睡前 30 分钟留给放松,不再继续刷任务。", + }, + { + "name": "整体气质", + "observations": ["气质偏克制", "亲和感与边界感并存"], + "interpretation": "整体气质象征你适合走可靠路线。事业或学习里,稳定交付比强行表现更能给你加分。", + "confidence": 0.67, + "advice": "把一个小成果整理出来发给该看到的人。", + }, + ], + "strengths": ["容易给人可靠感", "适合先观察再行动", "沟通时有稳定气场"], + "challenges": ["想得多时表达会慢半拍", "容易把疲惫藏起来"], + "suggestions": ["生活上先整理作息和精神状态", "学习成长上先列框架再输出", "事业工作上用稳定交付建立信任", "关系沟通上把需求说具体"], + "lucky_keywords": ["稳住气场", "先说重点", "整理状态"], + "disclaimer": DISCLAIMER, + } diff --git a/backend/app/services/palm_analyzer.py b/backend/app/services/palm_analyzer.py index 76ffb36..992dac9 100644 --- a/backend/app/services/palm_analyzer.py +++ b/backend/app/services/palm_analyzer.py @@ -1,18 +1,4 @@ -import base64 -import json - -from openai import AsyncOpenAI - -from app.core.config import settings - -DISCLAIMER = "本报告仅用于娱乐占卜与自我反思,不构成医学、心理、职业、财务、投资或任何人生决策建议。" -SYSTEM_PROMPT = ( - "你是“赛博先生”,一个面向普通人的娱乐型 AI 命理解读助手。" - "你的风格要像一个会聊天、懂生活的朋友:温和、具体、接地气,有一点玄学仪式感,但不要装神秘。" - "你会根据掌心照片做象征性手相解读,但所有表达都必须使用“可能、倾向、适合、提醒你”这类非确定性措辞。" - "禁止给出医疗、心理诊断、投资、职业成败、婚恋结果、寿命、灾祸等确定性判断。" - "不要堆砌专业术语,不要写得像教材;每个结论都要落到生活、学习、事业、关系或近期行动里的具体场景。" -) +from app.services.analyzer_common import DISCLAIMER, BaseAnalyzer USER_PROMPT_TEMPLATE = ( "请分析这张{hand_side}手掌照片,生成中文手相报告。" @@ -28,105 +14,16 @@ USER_PROMPT_TEMPLATE = ( "8. 如果照片不够真实或不够清晰,要诚实降低 confidence,并在 reason 里说明。" ) - -REPORT_SCHEMA = { - "name": "palm_report", - "schema": { - "type": "object", - "additionalProperties": False, - "properties": { - "quality_check": { - "type": "object", - "additionalProperties": False, - "properties": { - "can_analyze": {"type": "boolean"}, - "reason": {"type": "string"}, - "confidence": {"type": "number"}, - }, - "required": ["can_analyze", "reason", "confidence"], - }, - "overall_summary": {"type": "string"}, - "dimensions": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": False, - "properties": { - "name": {"type": "string"}, - "observations": {"type": "array", "items": {"type": "string"}}, - "interpretation": {"type": "string"}, - "confidence": {"type": "number"}, - "advice": {"type": "string"}, - }, - "required": ["name", "observations", "interpretation", "confidence", "advice"], - }, - }, - "strengths": {"type": "array", "items": {"type": "string"}}, - "challenges": {"type": "array", "items": {"type": "string"}}, - "suggestions": {"type": "array", "items": {"type": "string"}}, - "lucky_keywords": {"type": "array", "items": {"type": "string"}}, - "disclaimer": {"type": "string"}, - }, - "required": [ - "quality_check", - "overall_summary", - "dimensions", - "strengths", - "challenges", - "suggestions", - "lucky_keywords", - "disclaimer", - ], - }, - "strict": True, -} - - -class PalmAnalyzer: - def __init__(self) -> None: - self.client = ( - AsyncOpenAI(api_key=settings.openai_api_key, base_url=settings.openai_base_url) - if settings.openai_api_key - else None - ) - +class PalmAnalyzer(BaseAnalyzer): async def analyze(self, image_bytes: bytes, content_type: str, hand_side: str) -> dict: if not self.client: return self._mock_report(hand_side) - image_data = base64.b64encode(image_bytes).decode("ascii") - response = await self.client.responses.create( - model=settings.openai_model, - input=[ - { - "role": "system", - "content": [ - { - "type": "input_text", - "text": SYSTEM_PROMPT, - } - ], - }, - { - "role": "user", - "content": [ - { - "type": "input_text", - "text": USER_PROMPT_TEMPLATE.format(hand_side=hand_side), - }, - { - "type": "input_image", - "image_url": f"data:{content_type};base64,{image_data}", - "detail": "high", - }, - ], - }, - ], - text={"format": {"type": "json_schema", **REPORT_SCHEMA}}, + return await self._create_image_report( + USER_PROMPT_TEMPLATE.format(hand_side=hand_side), + image_bytes, + content_type, ) - data = json.loads(response.output_text) - data["disclaimer"] = DISCLAIMER - return data def _mock_report(self, hand_side: str) -> dict: return { diff --git a/backend/app/services/reading_service.py b/backend/app/services/reading_service.py new file mode 100644 index 0000000..1f39abf --- /dev/null +++ b/backend/app/services/reading_service.py @@ -0,0 +1,59 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.reading import Reading +from app.models.uploaded_image import UploadedImage +from app.services.bazi_analyzer import BaziAnalyzer +from app.services.bazi_calculator import BaziCalculator +from app.services.face_analyzer import FaceAnalyzer +from app.services.image_service import ImageService +from app.services.palm_analyzer import PalmAnalyzer + + +class ReadingService: + async def generate(self, db: AsyncSession, reading_id: str) -> None: + result = await db.execute(select(Reading).where(Reading.id == reading_id)) + reading = result.scalar_one() + reading.status = "processing" + await db.flush() + + try: + if reading.reading_type == "palm": + data = await self._generate_palm(db, reading) + elif reading.reading_type == "face": + data = await self._generate_face(db, reading) + elif reading.reading_type == "bazi": + data = await self._generate_bazi(reading) + else: + raise ValueError(f"Unsupported reading type: {reading.reading_type}") + + reading.report_data = data + reading.status = "failed" if not data["quality_check"]["can_analyze"] else "completed" + reading.error_message = None if reading.status == "completed" else data["quality_check"]["reason"] + except Exception as exc: + reading.status = "failed" + reading.error_message = str(exc) + await db.commit() + + async def _generate_palm(self, db: AsyncSession, reading: Reading) -> dict: + image = await self._get_image(db, reading) + image_bytes = ImageService().read_bytes(image.storage_key) + hand_side = reading.input_data.get("hand_side", "unknown") + return await PalmAnalyzer().analyze(image_bytes, image.content_type, hand_side) + + async def _generate_face(self, db: AsyncSession, reading: Reading) -> dict: + image = await self._get_image(db, reading) + image_bytes = ImageService().read_bytes(image.storage_key) + return await FaceAnalyzer().analyze(image_bytes, image.content_type) + + async def _generate_bazi(self, reading: Reading) -> dict: + chart = BaziCalculator().calculate(reading.input_data) + reading.input_data = {**reading.input_data, "chart": chart} + return await BaziAnalyzer().analyze(chart) + + async def _get_image(self, db: AsyncSession, reading: Reading) -> UploadedImage: + result = await db.execute(select(UploadedImage).where(UploadedImage.id == reading.image_id)) + image = result.scalar_one_or_none() + if image is None: + raise ValueError("Image not found") + return image diff --git a/backend/requirements.txt b/backend/requirements.txt index fbb2c17..174b3e9 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,5 +10,6 @@ httpx==0.28.1 openai==2.34.0 Pillow==11.1.0 PyJWT==2.10.1 +lunar_python==1.4.8 pytest==8.3.4 pytest-asyncio==0.25.2 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 8530d1e..19dfff8 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1,11 +1,13 @@ import pytest from httpx import ASGITransport, AsyncClient +from app.core.database import init_db from app.main import app @pytest.mark.asyncio async def test_anonymous_login_returns_reusable_user_token(): + await init_db() transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: first = await client.post("/api/v1/auth/anonymous-login", json={"client_id": "browser-1"}) diff --git a/backend/tests/test_palm_analyzer.py b/backend/tests/test_palm_analyzer.py index 4e8ba48..947470e 100644 --- a/backend/tests/test_palm_analyzer.py +++ b/backend/tests/test_palm_analyzer.py @@ -1,11 +1,12 @@ import pytest -from app.services.palm_analyzer import DISCLAIMER, PalmAnalyzer +from app.services.analyzer_common import DISCLAIMER +from app.services.palm_analyzer import PalmAnalyzer @pytest.mark.asyncio async def test_mock_report_has_required_shape(monkeypatch): - monkeypatch.setattr("app.services.palm_analyzer.settings.openai_api_key", None) + monkeypatch.setattr("app.services.analyzer_common.settings.openai_api_key", None) analyzer = PalmAnalyzer() report = await analyzer.analyze(b"fake", "image/jpeg", "left") diff --git a/backend/tests/test_reading_analyzers.py b/backend/tests/test_reading_analyzers.py new file mode 100644 index 0000000..6838cd2 --- /dev/null +++ b/backend/tests/test_reading_analyzers.py @@ -0,0 +1,36 @@ +import pytest + +from app.services.analyzer_common import DISCLAIMER +from app.services.bazi_analyzer import BaziAnalyzer +from app.services.bazi_calculator import BaziCalculator +from app.services.face_analyzer import FaceAnalyzer + + +@pytest.mark.asyncio +async def test_face_mock_report_has_required_shape(monkeypatch): + monkeypatch.setattr("app.services.analyzer_common.settings.openai_api_key", None) + report = await FaceAnalyzer().analyze(b"fake", "image/jpeg") + + assert report["quality_check"]["can_analyze"] is True + assert len(report["dimensions"]) >= 6 + assert report["disclaimer"] == DISCLAIMER + + +@pytest.mark.asyncio +async def test_bazi_mock_report_has_required_shape(monkeypatch): + monkeypatch.setattr("app.services.analyzer_common.settings.openai_api_key", None) + chart = BaziCalculator().calculate( + { + "calendar_type": "solar", + "birth_date": "1992-08-18", + "birth_time": "09:30", + "time_unknown": False, + "birth_place": "广东深圳", + } + ) + report = await BaziAnalyzer().analyze(chart) + + assert chart["pillars"]["year"] + assert report["quality_check"]["can_analyze"] is True + assert len(report["dimensions"]) >= 6 + assert report["disclaimer"] == DISCLAIMER diff --git a/backend/tests/test_readings_api.py b/backend/tests/test_readings_api.py new file mode 100644 index 0000000..4675b36 --- /dev/null +++ b/backend/tests/test_readings_api.py @@ -0,0 +1,45 @@ +import pytest +from httpx import ASGITransport, AsyncClient + +from app.core.database import init_db +from app.main import app + + +@pytest.mark.asyncio +async def test_bazi_reading_lifecycle(monkeypatch): + monkeypatch.setattr("app.services.analyzer_common.settings.openai_api_key", None) + await init_db() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + auth = await client.post("/api/v1/auth/anonymous-login", json={"client_id": "readings-bazi"}) + token = auth.json()["access_token"] + headers = {"authorization": f"Bearer {token}"} + + created = await client.post( + "/api/v1/readings", + headers=headers, + json={ + "reading_type": "bazi", + "input_data": { + "calendar_type": "solar", + "birth_date": "1992-08-18", + "birth_time": "09:30", + "time_unknown": False, + "birth_place": "广东深圳", + }, + }, + ) + assert created.status_code == 201 + reading_id = created.json()["id"] + + detail = await client.get(f"/api/v1/readings/{reading_id}", headers=headers) + assert detail.status_code == 200 + assert detail.json()["reading_type"] == "bazi" + assert detail.json()["status"] in {"pending", "processing", "completed"} + + listing = await client.get("/api/v1/readings", headers=headers) + assert listing.status_code == 200 + assert any(item["id"] == reading_id for item in listing.json()) + + deleted = await client.delete(f"/api/v1/readings/{reading_id}", headers=headers) + assert deleted.status_code == 200 diff --git a/palm_reading.db b/palm_reading.db index e69de29..ac9fe14 100644 Binary files a/palm_reading.db and b/palm_reading.db differ diff --git a/web/app/archive/page.tsx b/web/app/archive/page.tsx new file mode 100644 index 0000000..c169409 --- /dev/null +++ b/web/app/archive/page.tsx @@ -0,0 +1,5 @@ +import PalmWebApp from "@/components/PalmWebApp"; + +export default function ArchivePage() { + return ; +} diff --git a/web/app/bazi/page.tsx b/web/app/bazi/page.tsx new file mode 100644 index 0000000..c669ae2 --- /dev/null +++ b/web/app/bazi/page.tsx @@ -0,0 +1,5 @@ +import PalmWebApp from "@/components/PalmWebApp"; + +export default function BaziPage() { + return ; +} diff --git a/web/app/face/page.tsx b/web/app/face/page.tsx new file mode 100644 index 0000000..710858e --- /dev/null +++ b/web/app/face/page.tsx @@ -0,0 +1,5 @@ +import PalmWebApp from "@/components/PalmWebApp"; + +export default function FacePage() { + return ; +} diff --git a/web/app/globals.css b/web/app/globals.css index a7f3c04..b9ea790 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -33,7 +33,8 @@ body { } button, -input { +input, +select { font: inherit; } @@ -394,6 +395,58 @@ p { margin-top: 16px; } +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + margin-top: 20px; +} + +.field, +.checkbox-line { + display: grid; + gap: 8px; +} + +.field-wide { + grid-column: 1 / -1; +} + +.field span { + color: var(--gold); + font: 700 12px ui-sans-serif, system-ui, sans-serif; + letter-spacing: 0.08em; +} + +.field input, +.field select { + width: 100%; + min-height: 46px; + border: 1px solid var(--line); + border-radius: 14px; + color: var(--paper); + background: rgba(8, 13, 12, 0.48); + padding: 0 13px; +} + +.field input:disabled { + color: rgba(245, 236, 216, 0.42); +} + +.checkbox-line { + align-content: end; + grid-template-columns: auto 1fr; + align-items: center; + min-height: 46px; + color: var(--paper-dim); +} + +.checkbox-line input { + width: 18px; + height: 18px; + accent-color: var(--gold); +} + .primary-action:disabled { opacity: 0.72; cursor: wait; @@ -825,6 +878,21 @@ p { font-size: 14px; } + .form-grid { + grid-template-columns: 1fr; + gap: 12px; + } + + .field-wide { + grid-column: auto; + } + + .field input, + .field select { + min-height: 44px; + font-size: 14px; + } + .archive-head { align-items: flex-start; } @@ -900,7 +968,7 @@ p { left: 12px; z-index: 30; display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(5, 1fr); gap: 6px; padding: 8px; border: 1px solid var(--line); @@ -919,7 +987,7 @@ p { border-radius: 16px; color: var(--paper-dim); background: transparent; - font: 700 12px ui-sans-serif, system-ui, sans-serif; + font: 700 11px ui-sans-serif, system-ui, sans-serif; } .mobile-tabbar span { diff --git a/web/app/page.tsx b/web/app/page.tsx index fc55aa7..5840892 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -1,5 +1,5 @@ import PalmWebApp from "@/components/PalmWebApp"; export default function Home() { - return ; + return ; } diff --git a/web/app/palm/page.tsx b/web/app/palm/page.tsx new file mode 100644 index 0000000..7745421 --- /dev/null +++ b/web/app/palm/page.tsx @@ -0,0 +1,5 @@ +import PalmWebApp from "@/components/PalmWebApp"; + +export default function PalmPage() { + return ; +} diff --git a/web/components/PalmWebApp.tsx b/web/components/PalmWebApp.tsx index d1544d5..907da15 100644 --- a/web/components/PalmWebApp.tsx +++ b/web/components/PalmWebApp.tsx @@ -1,14 +1,16 @@ "use client"; -import { ChangeEvent, useEffect, useMemo, useState } from "react"; +import { ChangeEvent, ReactNode, useEffect, useMemo, useState } from "react"; import { Dimension, HandSide, - Report, + Reading, + ReadingSummary, + ReadingType, ReportData, - ReportSummary, apiFetch, ensureToken, + uploadFaceImage, uploadPalmImage, } from "@/lib/api"; @@ -18,140 +20,237 @@ const handText: Record = { unknown: "不确定", }; -const statusText: Record = { +const statusText: Record = { pending: "等待中", processing: "生成中", completed: "已完成", failed: "失败", }; -const services = [ - { - id: "palm", +const readingMeta: Record = { + palm: { name: "手相", title: "掌心解读", + label: "PALM READING", description: "上传掌心照片,生成生活、学习、事业与关系提醒。", - status: "已开放", }, - { - id: "face", + face: { name: "面相", title: "气色与五官", - description: "未来支持面部特征与状态观察,适合做日常运势入口。", - status: "规划中", + label: "FACE READING", + description: "上传单人正脸照,观察脸型、五官、气色与表达状态。", }, - { - id: "bazi", + bazi: { name: "八字", title: "生辰格局", - description: "未来支持出生信息推演,沉淀长期个人档案。", - status: "规划中", + label: "BAZI READING", + description: "填写出生信息,排出四柱后生成生活化八字报告。", }, -]; +}; -export default function PalmWebApp() { +type View = "home" | ReadingType | "archive"; +const views: View[] = ["home", "palm", "face", "bazi", "archive"]; + +type BaziForm = { + nickname: string; + gender: string; + calendar_type: "solar" | "lunar"; + is_leap_month: boolean; + birth_date: string; + birth_time: string; + time_unknown: boolean; + birth_place: string; +}; + +const defaultBaziForm: BaziForm = { + nickname: "", + gender: "", + calendar_type: "solar", + is_leap_month: false, + birth_date: "", + birth_time: "12:00", + time_unknown: false, + birth_place: "", +}; + +export default function PalmWebApp({ initialView = "home" }: { initialView?: View }) { const [ready, setReady] = useState(false); - const [file, setFile] = useState(null); - const [preview, setPreview] = useState(""); + const [palmFile, setPalmFile] = useState(null); + const [faceFile, setFaceFile] = useState(null); + const [palmPreview, setPalmPreview] = useState(""); + const [facePreview, setFacePreview] = useState(""); const [handSide, setHandSide] = useState("unknown"); + const [baziForm, setBaziForm] = useState(defaultBaziForm); const [busyText, setBusyText] = useState(""); const [error, setError] = useState(""); - const [reports, setReports] = useState([]); - const [activeReport, setActiveReport] = useState(null); - const [activeView, setActiveView] = useState<"home" | "palm" | "archive">("home"); + const [readings, setReadings] = useState([]); + const [activeReading, setActiveReading] = useState(null); + const [activeView, setActiveView] = useState(initialView); const [activeJobId, setActiveJobId] = useState(""); + const [activeJobType, setActiveJobType] = useState("palm"); useEffect(() => { + setActiveView(readViewFromPath(initialView)); + const onPopState = () => setActiveView(readViewFromPath(initialView)); + window.addEventListener("popstate", onPopState); + ensureToken() - .then(() => Promise.all([loadReports()])) + .then(() => loadReadings()) .then(() => setReady(true)) .catch((err) => { setError(err.message || "初始化失败"); setReady(true); }); - }, []); + + return () => window.removeEventListener("popstate", onPopState); + }, [initialView]); useEffect(() => { return () => { - if (preview) URL.revokeObjectURL(preview); + if (palmPreview) URL.revokeObjectURL(palmPreview); + if (facePreview) URL.revokeObjectURL(facePreview); }; - }, [preview]); + }, [palmPreview, facePreview]); - const completedReports = reports.filter((item) => item.status === "completed").length; - const latestReport = reports[0]; + const completedReadings = readings.filter((item) => item.status === "completed").length; + const latestReading = readings[0]; const hasActiveJob = Boolean(activeJobId); - function onPickFile(event: ChangeEvent) { + function pickFile(event: ChangeEvent, type: "palm" | "face") { const selected = event.target.files?.[0]; if (!selected) return; - setFile(selected); setError(""); - if (preview) URL.revokeObjectURL(preview); - setPreview(URL.createObjectURL(selected)); + const url = URL.createObjectURL(selected); + if (type === "palm") { + if (palmPreview) URL.revokeObjectURL(palmPreview); + setPalmFile(selected); + setPalmPreview(url); + } else { + if (facePreview) URL.revokeObjectURL(facePreview); + setFaceFile(selected); + setFacePreview(url); + } } - async function loadReports() { - const data = await apiFetch("/reports"); - setReports(data); + async function loadReadings() { + const data = await apiFetch("/readings"); + setReadings(data); } - async function startReport() { + async function startImageReading(type: "palm" | "face") { + const file = type === "palm" ? palmFile : faceFile; if (!file) { - setError("请先选择一张清晰的掌心照片。"); + setError(type === "palm" ? "请先选择一张清晰的掌心照片。" : "请先选择一张清晰的单人正脸照片。"); return; } - setBusyText("正在接入掌纹照片..."); + setBusyText(type === "palm" ? "正在接入掌纹照片..." : "正在接入正脸照片..."); setError(""); try { - const upload = await uploadPalmImage(file); - const report = await apiFetch("/reports", { + const upload = type === "palm" ? await uploadPalmImage(file) : await uploadFaceImage(file); + const reading = await apiFetch("/readings", { method: "POST", - body: JSON.stringify({ image_id: upload.image_id, hand_side: handSide }), + body: JSON.stringify({ + reading_type: type, + image_id: upload.image_id, + input_data: type === "palm" ? { hand_side: handSide } : { photo_style: "front_single" }, + }), }); - setActiveReport(report); - setActiveJobId(report.id); - setBusyText(""); - setFile(null); - setPreview(""); - void pollReport(report.id) - .then(() => loadReports()) - .catch((err) => setError(err instanceof Error ? err.message : "报告生成失败")) - .finally(() => setActiveJobId("")); - await loadReports(); + afterTaskCreated(reading); + if (type === "palm") { + setPalmFile(null); + setPalmPreview(""); + } else { + setFaceFile(null); + setFacePreview(""); + } } catch (err) { - setError(err instanceof Error ? err.message : "生成失败"); + setError(err instanceof Error ? err.message : "提交失败"); setBusyText(""); } } - async function pollReport(reportId: string) { + async function startBaziReading() { + if (!baziForm.birth_date) { + setError("请先填写出生日期。"); + return; + } + if (!baziForm.time_unknown && !baziForm.birth_time) { + setError("请选择出生时间,或勾选“不确定时辰”。"); + return; + } + setBusyText("正在排出四柱..."); + setError(""); + try { + const reading = await apiFetch("/readings", { + method: "POST", + body: JSON.stringify({ + reading_type: "bazi", + input_data: { + ...baziForm, + nickname: baziForm.nickname || null, + gender: baziForm.gender || null, + birth_place: baziForm.birth_place || null, + }, + }), + }); + afterTaskCreated(reading); + } catch (err) { + setError(err instanceof Error ? err.message : "提交失败"); + setBusyText(""); + } + } + + function afterTaskCreated(reading: Reading) { + setActiveReading(reading); + setActiveJobId(reading.id); + setActiveJobType(reading.reading_type); + setBusyText(""); + void pollReading(reading.id) + .then(() => loadReadings()) + .catch((err) => setError(err instanceof Error ? err.message : "报告生成失败")) + .finally(() => setActiveJobId("")); + void loadReadings(); + } + + async function pollReading(readingId: string) { for (let i = 0; i < 80; i += 1) { - const report = await apiFetch(`/reports/${reportId}`); - setActiveReport(report); - if (report.status === "completed") return report; - if (report.status === "failed") throw new Error(report.error_message || "报告生成失败"); + const reading = await apiFetch(`/readings/${readingId}`); + setActiveReading(reading); + if (reading.status === "completed") return reading; + if (reading.status === "failed") throw new Error(reading.error_message || "报告生成失败"); await sleep(2200); } throw new Error("生成时间较长,请稍后在档案中查看。"); } - async function openReport(reportId: string) { + async function openReading(readingId: string) { setError(""); - const report = await apiFetch(`/reports/${reportId}`); - setActiveReport(report); + const reading = await apiFetch(`/readings/${readingId}`); + setActiveReading(reading); + navigate("archive"); window.scrollTo({ top: 0, behavior: "smooth" }); } - async function deleteReport(reportId: string) { - await apiFetch(`/reports/${reportId}`, { method: "DELETE" }); - if (activeReport?.id === reportId) setActiveReport(null); - await loadReports(); + async function deleteReading(readingId: string) { + await apiFetch(`/readings/${readingId}`, { method: "DELETE" }); + if (activeReading?.id === readingId) setActiveReading(null); + await loadReadings(); + } + + function navigate(view: View) { + setError(""); + setActiveView(view); + window.history.pushState(null, "", pathForView(view)); + if (view !== "archive") { + setActiveReading(null); + } + window.scrollTo({ top: 0, behavior: "smooth" }); } return (
-
{hasActiveJob ? ( - ) : null} @@ -178,52 +279,39 @@ export default function PalmWebApp() {

AI 玄学档案 · 娱乐占卜 · 自我反思

把日常困惑,交给先生慢慢看。

-

从手相开始,逐步扩展到面相、八字与个人长期档案。每一次解读都更贴近生活、学习、事业与关系。

+

从手相、面相到八字,每一次解读都更贴近生活、学习、事业与关系。

- {services.map((service) => ( - ))}
- - +
) : null} {activeView === "palm" ? ( -
-
-
PALM READING
-

上传掌心照片

-

掌心完整入镜、光线充足、纹路清晰。左右手不确定可以选“不确定”。

- - - + pickFile(event, "palm")} + onSubmit={() => startImageReading("palm")} + extra={
{(["left", "right", "unknown"] as HandSide[]).map((side) => ( ))}
+ } + stats={} + /> + ) : null} - - {error ?

{error}

: null} -
+ {activeView === "face" ? ( + pickFile(event, "face")} + onSubmit={() => startImageReading("face")} + extra={null} + stats={} + /> + ) : null} - -
+ {activeView === "bazi" ? ( + } + /> ) : null} {activeView === "archive" ? ( @@ -261,14 +357,14 @@ export default function PalmWebApp() {

MISTER ARCHIVE

个人玄学档案

- +
- {reports.length ? ( - reports.map((item) => ( -
+ {activeReading ? deleteReading(activeReading.id)} /> : null} ) : null} - {activeReport ? ( - deleteReport(activeReport.id)} - /> - ) : null} -
); } -function ReportPanel({ - report, - onDelete, +function ImageReadingForm({ + type, + preview, + busyText, + ready, + error, + extra, + stats, + onPickFile, + onSubmit, }: { - report: Report; - onDelete: () => void; + type: "palm" | "face"; + preview: string; + busyText: string; + ready: boolean; + error: string; + extra: ReactNode; + stats: ReactNode; + onPickFile: (event: ChangeEvent) => void; + onSubmit: () => void; }) { - const data = report.report_data; + const isPalm = type === "palm"; + return ( +
+
+
{readingMeta[type].label}
+

{isPalm ? "上传掌心照片" : "上传正脸照片"}

+

{isPalm ? "掌心完整入镜、光线充足、纹路清晰。左右手不确定可以选“不确定”。" : "请上传单人正脸照,五官无遮挡,光线自然清晰。"}

+ + + + {extra} + + {error ?

{error}

: null} +
+ + +
+ ); +} + +function BaziFormView({ + form, + setForm, + busyText, + ready, + error, + stats, + onSubmit, +}: { + form: BaziForm; + setForm: (form: BaziForm) => void; + busyText: string; + ready: boolean; + error: string; + stats: ReactNode; + onSubmit: () => void; +}) { + return ( +
+
+
BAZI READING
+

填写生辰信息

+

第一版按标准专业排盘,不做真太阳时校正。出生地会用于报告语境,不用于经度校正。

+ +
+ + + + {form.calendar_type === "lunar" ? ( + + ) : null} + + + + +
+ + + {error ?

{error}

: null} +
+ + +
+ ); +} + +function ReadingStats({ total, completed }: { total: number; completed: number }) { + return ( +
+ + +
+ ); +} + +function ReportPanel({ reading, onDelete }: { reading: Reading; onDelete: () => void }) { + const data = reading.report_data; const score = useMemo(() => getScore(data), [data]); + const type = reading.reading_type; + const handSide = (reading.input_data?.hand_side as HandSide | undefined) || "unknown"; if (!data) { return (
-

{statusText[report.status]}

-

{report.error_message || "先生正在整理报告。"}

+

{statusText[reading.status]}

+

{reading.error_message || "先生正在整理报告。"}

); } @@ -329,8 +565,11 @@ function ReportPanel({
-

PALM REPORT · {handText[report.hand_side]}

-

赛博先生手相报告

+

+ {readingMeta[type].label} + {type === "palm" ? ` · ${handText[handSide]}` : ""} +

+

赛博先生{readingMeta[type].name}报告

{data.overall_summary}

@@ -413,6 +652,16 @@ function formatDate(value: string) { return value.replace("T", " ").slice(0, 16); } +function readViewFromPath(fallback: View): View { + if (typeof window === "undefined") return "home"; + const segment = window.location.pathname.replace(/^\/+/, "").split("/")[0] || "home"; + return views.includes(segment as View) ? (segment as View) : fallback; +} + +function pathForView(view: View) { + return view === "home" ? "/" : `/${view}`; +} + function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/web/lib/api.ts b/web/lib/api.ts index 0a161ee..8b51813 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -4,6 +4,7 @@ const TOKEN_KEY = "cyber_mister_token"; const CLIENT_KEY = "cyber_mister_client_id"; export type HandSide = "left" | "right" | "unknown"; +export type ReadingType = "palm" | "face" | "bazi"; export type Dimension = { name: string; @@ -28,24 +29,28 @@ export type ReportData = { disclaimer: string; }; -export type Report = { +export type Reading = { id: string; + reading_type: ReadingType; status: "pending" | "processing" | "completed" | "failed"; - hand_side: HandSide; + input_data: Record; error_message?: string | null; report_data?: ReportData | null; created_at: string; updated_at: string; }; -export type ReportSummary = { +export type ReadingSummary = { id: string; - status: Report["status"]; - hand_side: HandSide; + reading_type: ReadingType; + status: Reading["status"]; created_at: string; overall_summary?: string | null; }; +export type Report = Reading & { hand_side?: HandSide }; +export type ReportSummary = ReadingSummary & { hand_side?: HandSide }; + export function getStoredToken() { if (typeof window === "undefined") return ""; return localStorage.getItem(TOKEN_KEY) || ""; @@ -89,11 +94,19 @@ export async function apiFetch(path: string, options: RequestInit = {}): Prom } export async function uploadPalmImage(file: File) { + return uploadImage(file, "palm"); +} + +export async function uploadFaceImage(file: File) { + return uploadImage(file, "face"); +} + +async function uploadImage(file: File, type: "palm" | "face") { const token = await ensureToken(); const formData = new FormData(); formData.append("file", file); - const response = await fetch(`${API_BASE_URL}/uploads/palm`, { + const response = await fetch(`${API_BASE_URL}/uploads/${type}`, { method: "POST", headers: { authorization: `Bearer ${token}` }, body: formData,