diff --git a/backend/alembic/versions/20260407_0003_drop_duplicate_indexes.py b/backend/alembic/versions/20260407_0003_drop_duplicate_indexes.py new file mode 100644 index 0000000..3323f5d --- /dev/null +++ b/backend/alembic/versions/20260407_0003_drop_duplicate_indexes.py @@ -0,0 +1,25 @@ +"""drop duplicate indexes on llm_factory and llms + +Revision ID: 20260407_0003 +Revises: 20260407_0002 +Create Date: 2026-04-07 00:00:00 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "20260407_0003" +down_revision: Union[str, Sequence[str], None] = "20260407_0002" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_llm_factory_name") + op.execute("DROP INDEX IF EXISTS ix_llms_model_code") + + +def downgrade() -> None: + op.execute("CREATE INDEX ix_llm_factory_name ON llm_factory(name)") + op.execute("CREATE INDEX ix_llms_model_code ON llms(model_code)") diff --git a/backend/src/models/__init__.py b/backend/src/models/__init__.py index fdaa38f..bf9ab4b 100644 --- a/backend/src/models/__init__.py +++ b/backend/src/models/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from .agent_chat_message import AgentChatMessage from .agent_chat_session import AgentChatSession from .auth_user import AuthUser +from .invite_code import InviteCode from .llm import Llm from .llm_factory import LlmFactory from .points_ledger import PointsLedger @@ -14,6 +15,7 @@ __all__ = [ "AgentChatMessage", "AgentChatSession", "AuthUser", + "InviteCode", "Llm", "LlmFactory", "PointsLedger", diff --git a/backend/src/models/llm.py b/backend/src/models/llm.py index 7cd2a74..a8d73f6 100644 --- a/backend/src/models/llm.py +++ b/backend/src/models/llm.py @@ -21,6 +21,4 @@ class Llm(TimestampMixin, SoftDeleteMixin, Base): nullable=False, index=True, ) - model_code: Mapped[str] = mapped_column( - String(50), nullable=False, unique=True, index=True - ) + model_code: Mapped[str] = mapped_column(String(50), nullable=False, unique=True) diff --git a/backend/src/models/llm_factory.py b/backend/src/models/llm_factory.py index c45f93d..8bf4249 100644 --- a/backend/src/models/llm_factory.py +++ b/backend/src/models/llm_factory.py @@ -15,8 +15,6 @@ class LlmFactory(TimestampMixin, SoftDeleteMixin, Base): id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) - name: Mapped[str] = mapped_column( - String(50), nullable=False, unique=True, index=True - ) + name: Mapped[str] = mapped_column(String(50), nullable=False, unique=True) request_url: Mapped[str] = mapped_column(String(255), nullable=False) avatar: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/src/models/profile.py b/backend/src/models/profile.py index 3317f1a..8e14b1e 100644 --- a/backend/src/models/profile.py +++ b/backend/src/models/profile.py @@ -34,3 +34,8 @@ class Profile(TimestampMixin, SoftDeleteMixin, Base): server_default=text("'{}'::jsonb"), default=dict, ) + referred_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("profiles.id", ondelete="SET NULL"), + nullable=True, + ) diff --git a/backend/src/schemas/agent/runtime_models.py b/backend/src/schemas/agent/runtime_models.py index 144c277..97c7ed9 100644 --- a/backend/src/schemas/agent/runtime_models.py +++ b/backend/src/schemas/agent/runtime_models.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import Enum from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field from schemas.agent.ui_hints import UiHintsPayload from schemas.domain.divination import DerivedDivinationData @@ -45,7 +45,6 @@ class WorkerAgentOutputLite(BaseModel): status: RunStatus = RunStatus.SUCCESS sign_level: Literal["上上签", "中上签", "中下签", "下下签"] - summary: str = Field(min_length=1, max_length=300) conclusion: list[str] = Field(min_length=1, max_length=6) focus_points: list[str] = Field(default_factory=list, max_length=6) advice: list[str] = Field(min_length=1, max_length=6) @@ -53,20 +52,6 @@ class WorkerAgentOutputLite(BaseModel): answer: str = Field(min_length=1, max_length=4000) error: ErrorInfo | None = None - # Backward-compatible shadow fields for legacy consumers. - key_points: list[str] = Field(default_factory=list, max_length=6) - result_type: str = Field(default="structured_payload") - suggested_actions: list[str] = Field(default_factory=list, max_length=6) - divination_derived: DerivedDivinationData | None = None - - @model_validator(mode="after") - def sync_compatibility_fields(self) -> WorkerAgentOutputLite: - if not self.key_points and self.focus_points: - self.key_points = list(self.focus_points) - if not self.suggested_actions and self.advice: - self.suggested_actions = list(self.advice) - return self - class WorkerAgentOutputRich(WorkerAgentOutputLite): ui_hints: UiHintsPayload | None = None @@ -75,6 +60,8 @@ class WorkerAgentOutputRich(WorkerAgentOutputLite): class AgentOutput(WorkerAgentOutputRich): model_config = ConfigDict(extra="forbid") + divination_derived: DerivedDivinationData | None = None + WorkerAgentOutput = WorkerAgentOutputLite | WorkerAgentOutputRich diff --git a/backend/src/schemas/domain/automation.py b/backend/src/schemas/domain/automation.py deleted file mode 100644 index cfd757f..0000000 --- a/backend/src/schemas/domain/automation.py +++ /dev/null @@ -1,124 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from enum import Enum -from typing import Protocol -from uuid import UUID - -from core.agentscope.tools.tool_config import AgentTool -from pydantic import BaseModel, ConfigDict, Field, model_validator -from schemas.enums import AutomationJobStatus, ScheduleType - - -class AutomationJobLike(Protocol): - id: UUID - owner_id: UUID - bootstrap_key: str | None - title: str - config: dict[str, object] - next_run_at: datetime - timezone: str - last_run_at: datetime | None - status: AutomationJobStatus - created_by: UUID | None - created_at: datetime - updated_at: datetime - - -class ContextSource(str, Enum): - LATEST_CHAT = "latest_chat" - - -class ContextWindowMode(str, Enum): - DAY = "day" - NUMBER = "number" - - -class MessageContextConfig(BaseModel): - model_config = ConfigDict(extra="forbid") - - source: ContextSource = ContextSource.LATEST_CHAT - window_mode: ContextWindowMode = ContextWindowMode.DAY - window_count: int = Field(default=2, ge=1, le=200) - - -class ScheduleRunAt(BaseModel): - model_config = ConfigDict(extra="forbid") - - hour: int = Field(default=8, ge=0, le=23) - minute: int = Field(default=0, ge=0, le=59) - - -class ScheduleConfig(BaseModel): - model_config = ConfigDict(extra="forbid") - - type: ScheduleType - run_at: ScheduleRunAt - weekdays: list[int] | None = None - - @model_validator(mode="after") - def validate_weekdays(self) -> "ScheduleConfig": - if self.type == ScheduleType.WEEKLY: - if not self.weekdays: - raise ValueError("weekdays is required when schedule type is weekly") - invalid = [day for day in self.weekdays if day < 1 or day > 7] - if invalid: - raise ValueError("weekdays must be within 1-7") - self.weekdays = sorted(set(self.weekdays)) - else: - self.weekdays = None - return self - - -class RuntimeConfig(BaseModel): - model_config = ConfigDict(extra="forbid") - - enabled_tools: list[AgentTool] = Field(default_factory=list, max_length=32) - context: MessageContextConfig = Field(default_factory=MessageContextConfig) - - -class AutomationJobConfig(BaseModel): - model_config = ConfigDict(extra="forbid") - - enabled_tools: list[AgentTool] | None = Field(default=None, max_length=32) - context: MessageContextConfig | None = None - input_template: str | None = Field(default=None, min_length=1, max_length=4000) - schedule: ScheduleConfig | None = None - - -class AutomationJob(BaseModel): - model_config = ConfigDict(extra="forbid") - - id: UUID - owner_id: UUID - bootstrap_key: str | None = Field(default=None, min_length=1, max_length=64) - title: str = Field(..., min_length=1, max_length=255) - config: AutomationJobConfig - next_run_at: datetime - timezone: str = Field(default="UTC", min_length=1, max_length=50) - last_run_at: datetime | None = None - status: AutomationJobStatus - created_by: UUID | None = None - created_at: datetime - updated_at: datetime - - @classmethod - def from_orm(cls, obj: object) -> "AutomationJob": - return cls( - id=getattr(obj, "id"), - owner_id=getattr(obj, "owner_id"), - bootstrap_key=getattr(obj, "bootstrap_key"), - title=getattr(obj, "title"), - config=AutomationJobConfig.model_validate(getattr(obj, "config", {}) or {}), - next_run_at=getattr(obj, "next_run_at"), - timezone=getattr(obj, "timezone"), - last_run_at=getattr(obj, "last_run_at"), - status=getattr(obj, "status"), - created_by=getattr(obj, "created_by"), - created_at=getattr(obj, "created_at"), - updated_at=getattr(obj, "updated_at"), - ) - - @property - def is_system(self) -> bool: - return self.bootstrap_key is not None diff --git a/backend/src/schemas/domain/chat_session.py b/backend/src/schemas/domain/chat_session.py deleted file mode 100644 index 42440a6..0000000 --- a/backend/src/schemas/domain/chat_session.py +++ /dev/null @@ -1,11 +0,0 @@ -from __future__ import annotations - -from typing import ClassVar - -from pydantic import BaseModel, ConfigDict - - -class SessionStateSnapshot(BaseModel): - model_config: ClassVar[ConfigDict] = ConfigDict(extra="allow") - - pass diff --git a/backend/src/schemas/domain/inbox.py b/backend/src/schemas/domain/inbox.py deleted file mode 100644 index d33129a..0000000 --- a/backend/src/schemas/domain/inbox.py +++ /dev/null @@ -1,81 +0,0 @@ -from __future__ import annotations - -import json -from typing import ClassVar, Literal, Union - -from pydantic import BaseModel, ConfigDict, Field -from schemas.enums import InboxMessageStatus, InboxMessageType - -__all__ = [ - "InboxMessageType", - "InboxMessageStatus", - "CalendarInviteContent", - "CalendarUpdateContent", - "CalendarDeleteContent", - "FriendshipContent", - "CalendarContent", - "InboxMessageContent", - "parse_calendar_content", -] - - -class CalendarInviteContent(BaseModel): - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - - type: Literal["invite"] - permission: int = Field(..., description="权限: 1=view, 4=edit, 8=invite") - action: Literal["pending"] = "pending" - - -class CalendarUpdateContent(BaseModel): - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - - type: Literal["update"] - title: str = Field(..., description="事件标题") - action: Literal["updated"] = "updated" - - -class CalendarDeleteContent(BaseModel): - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - - type: Literal["delete"] - title: str = Field(..., description="事件标题") - action: Literal["deleted"] = "deleted" - - -class FriendshipContent(BaseModel): - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - - type: Literal["request"] - message: str | None = Field(None, description="好友申请消息") - - -CalendarContent = Union[ - CalendarInviteContent, - CalendarUpdateContent, - CalendarDeleteContent, -] - -InboxMessageContent = Union[ - CalendarInviteContent, - CalendarUpdateContent, - CalendarDeleteContent, - FriendshipContent, -] - - -def parse_calendar_content(content: str | None) -> CalendarContent | None: - if not content: - return None - try: - data = json.loads(content) - content_type = data.get("type") - if content_type == "invite": - return CalendarInviteContent(**data) - if content_type == "update": - return CalendarUpdateContent(**data) - if content_type == "delete": - return CalendarDeleteContent(**data) - raise ValueError(f"Unknown calendar content type: {content_type}") - except Exception: - return None diff --git a/backend/src/schemas/domain/points.py b/backend/src/schemas/domain/points.py index 36c875c..86a8365 100644 --- a/backend/src/schemas/domain/points.py +++ b/backend/src/schemas/domain/points.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime from decimal import Decimal from typing import Literal from uuid import UUID @@ -7,6 +8,7 @@ from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, model_validator from ..enums import ( + PointsBizType, PointsChangeType, PointsOperatorType, ) @@ -76,3 +78,51 @@ def parse_points_ledger_metadata( if change_type == PointsChangeType.GRANT: return GrantLedgerMetadata.model_validate(payload) return AdjustLedgerMetadata.model_validate(payload) + + +class ApplyPointsChangeCommand(BaseModel): + model_config = ConfigDict(extra="forbid") + + user_id: UUID + change_type: PointsChangeType + biz_type: PointsBizType | None = None + biz_id: UUID | None = None + event_id: str = Field(min_length=1, max_length=64) + amount: int = Field(gt=0) + direction: Literal[1, -1] + operator_id: UUID | None = None + metadata: PointsLedgerMetadata + occurred_at: datetime | None = None + + @model_validator(mode="after") + def validate_change_type_contract(self) -> "ApplyPointsChangeCommand": + if self.change_type == PointsChangeType.REGISTER: + if ( + self.direction != 1 + or self.biz_type is not None + or self.biz_id is not None + ): + raise ValueError("register must use direction=1 and no biz binding") + return self + + if self.change_type == PointsChangeType.CONSUME: + if ( + self.direction != -1 + or self.biz_type != PointsBizType.CHAT + or self.biz_id is None + ): + raise ValueError("consume must use direction=-1 and chat binding") + return self + + if self.change_type == PointsChangeType.GRANT: + if ( + self.direction != 1 + or self.biz_type != PointsBizType.CHAT + or self.biz_id is None + ): + raise ValueError("grant must use direction=1 and chat binding") + return self + + if self.biz_type != PointsBizType.CHAT or self.biz_id is None: + raise ValueError("adjust must use chat binding") + return self diff --git a/backend/src/schemas/domain/schedule.py b/backend/src/schemas/domain/schedule.py deleted file mode 100644 index 10c276f..0000000 --- a/backend/src/schemas/domain/schedule.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -from enum import Enum -from typing import ClassVar, Literal -from uuid import UUID - -from pydantic import BaseModel, ConfigDict, Field -from schemas.enums import ScheduleItemSourceType, ScheduleItemStatus - -__all__ = [ - "AttachmentType", - "ScheduleItemMetadataAttachment", - "ScheduleItemMetadata", - "ScheduleItemSourceType", - "ScheduleItemStatus", -] - - -class AttachmentType(str, Enum): - DOCUMENT = "document" - REMINDER = "reminder" - - -class ScheduleItemMetadataAttachment(BaseModel): - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - - name: str - type: AttachmentType - visible_to: list[UUID] = Field(default_factory=list) - url: str | None = None - note: str | None = None - content: str | None = None - - -class ScheduleItemMetadata(BaseModel): - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - - color: str | None = Field(default=None, pattern=r"^#[0-9A-Fa-f]{6}$") - location: str | None = None - notes: str | None = None - attachments: list[ScheduleItemMetadataAttachment] = Field(default_factory=list) - reminder_minutes: int | None = Field(default=None, ge=0, le=10080) - version: Literal[1] = 1 diff --git a/backend/src/schemas/domain/todo.py b/backend/src/schemas/domain/todo.py deleted file mode 100644 index b98df2f..0000000 --- a/backend/src/schemas/domain/todo.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import annotations - -from typing import Annotated - -from pydantic import Field - -TodoOrder = Annotated[int, Field(ge=0)] diff --git a/backend/src/schemas/shared/points.py b/backend/src/schemas/shared/points.py deleted file mode 100644 index 2e521ee..0000000 --- a/backend/src/schemas/shared/points.py +++ /dev/null @@ -1,88 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from typing import Literal -from uuid import UUID - -from pydantic import BaseModel, ConfigDict, Field, model_validator - -from ..domain.points import PointsLedgerMetadata -from ..enums import PointsBizType, PointsChangeType - - -class UserPointsSnapshot(BaseModel): - model_config = ConfigDict(from_attributes=True) - - user_id: UUID - balance: int = Field(ge=0) - frozen_balance: int = Field(ge=0) - lifetime_earned: int = Field(ge=0) - lifetime_spent: int = Field(ge=0) - version: int = Field(ge=0) - created_at: datetime - updated_at: datetime - - -class PointsLedgerEntry(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: UUID - user_id: UUID - direction: Literal[1, -1] - amount: int = Field(gt=0) - balance_after: int = Field(ge=0) - change_type: PointsChangeType - biz_type: PointsBizType | None = None - biz_id: UUID | None = None - event_id: str = Field(min_length=1, max_length=64) - operator_id: UUID | None = None - metadata: PointsLedgerMetadata = Field(validation_alias="metadata_json") - created_at: datetime - - -class ApplyPointsChangeCommand(BaseModel): - model_config = ConfigDict(extra="forbid") - - user_id: UUID - change_type: PointsChangeType - biz_type: PointsBizType | None = None - biz_id: UUID | None = None - event_id: str = Field(min_length=1, max_length=64) - amount: int = Field(gt=0) - direction: Literal[1, -1] - operator_id: UUID | None = None - metadata: PointsLedgerMetadata - occurred_at: datetime | None = None - - @model_validator(mode="after") - def validate_change_type_contract(self) -> "ApplyPointsChangeCommand": - if self.change_type == PointsChangeType.REGISTER: - if ( - self.direction != 1 - or self.biz_type is not None - or self.biz_id is not None - ): - raise ValueError("register must use direction=1 and no biz binding") - return self - - if self.change_type == PointsChangeType.CONSUME: - if ( - self.direction != -1 - or self.biz_type != PointsBizType.CHAT - or self.biz_id is None - ): - raise ValueError("consume must use direction=-1 and chat binding") - return self - - if self.change_type == PointsChangeType.GRANT: - if ( - self.direction != 1 - or self.biz_type != PointsBizType.CHAT - or self.biz_id is None - ): - raise ValueError("grant must use direction=1 and chat binding") - return self - - if self.biz_type != PointsBizType.CHAT or self.biz_id is None: - raise ValueError("adjust must use chat binding") - return self diff --git a/backend/src/schemas/shared/user.py b/backend/src/schemas/shared/user.py index 716a433..0011f6b 100644 --- a/backend/src/schemas/shared/user.py +++ b/backend/src/schemas/shared/user.py @@ -41,17 +41,22 @@ class PreferenceSettings(BaseModel): return normalized +class NotificationSettings(BaseModel): + allow_notifications: bool = True + allow_vibration: bool = True + + class ProfileSettingsV1(BaseModel): version: Literal[1] = 1 preferences: PreferenceSettings = Field(default_factory=PreferenceSettings) - privacy: dict = Field(default_factory=dict) - notification: dict = Field(default_factory=dict) + privacy: dict[str, object] = Field(default_factory=dict) + notification: NotificationSettings = Field(default_factory=NotificationSettings) ProfileSettingsUnion = ProfileSettingsV1 -def parse_profile_settings(raw: dict | None) -> ProfileSettingsUnion: +def parse_profile_settings(raw: dict[str, object] | None) -> ProfileSettingsUnion: payload = dict(raw or {}) payload.setdefault("version", 1) return ProfileSettingsV1.model_validate(payload) diff --git a/backend/src/v1/agent/schemas.py b/backend/src/v1/agent/schemas.py index c0d3063..a76d2a7 100644 --- a/backend/src/v1/agent/schemas.py +++ b/backend/src/v1/agent/schemas.py @@ -208,15 +208,11 @@ class HistoryAgentOutput(BaseModel): status: Literal["success", "failed"] | None = None sign_level: Literal["上上签", "中上签", "中下签", "下下签"] | None = None - summary: str | None = None conclusion: list[str] = Field(default_factory=list) focus_points: list[str] = Field(default_factory=list) advice: list[str] = Field(default_factory=list) keywords: list[str] = Field(default_factory=list) answer: str | None = None - key_points: list[str] = Field(default_factory=list) - result_type: str | None = None - suggested_actions: list[str] = Field(default_factory=list) divination_derived: DerivedDivinationData | None = None diff --git a/backend/src/v1/agent/utils.py b/backend/src/v1/agent/utils.py index 6401b98..519dd8f 100644 --- a/backend/src/v1/agent/utils.py +++ b/backend/src/v1/agent/utils.py @@ -109,11 +109,7 @@ def _extract_worker_agent_output( try: agent_output = AgentOutput.model_validate(agent_output_data) except Exception: - normalized_payload = _normalize_agent_output_payload(agent_output_data) - try: - agent_output = AgentOutput.model_validate(normalized_payload) - except Exception: - return None + return None if not agent_output: return None @@ -123,37 +119,6 @@ def _extract_worker_agent_output( return payload or None -def _normalize_agent_output_payload(agent_output_data: Any) -> dict[str, Any] | None: - if not isinstance(agent_output_data, dict): - return None - normalized = dict(agent_output_data) - derived = normalized.get("divination_derived") - if isinstance(derived, dict): - normalized["divination_derived"] = _normalize_divination_derived(derived) - return normalized - - -def _normalize_divination_derived(value: Any) -> Any: - if isinstance(value, dict): - result: dict[str, Any] = {} - for key, item in value.items(): - normalized_key = _snake_to_camel(key) - result[normalized_key] = _normalize_divination_derived(item) - return result - if isinstance(value, list): - return [_normalize_divination_derived(item) for item in value] - return value - - -def _snake_to_camel(value: str) -> str: - if "_" not in value: - return value - parts = value.split("_") - if not parts: - return value - return parts[0] + "".join(part[:1].upper() + part[1:] for part in parts[1:]) - - def mime_to_suffix(mime_type: str) -> str: mapping = { "image/png": "png", diff --git a/backend/src/v1/points/repository.py b/backend/src/v1/points/repository.py index b48e672..fd9d514 100644 --- a/backend/src/v1/points/repository.py +++ b/backend/src/v1/points/repository.py @@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from models.points_ledger import PointsLedger from models.user_points import UserPoints -from schemas.shared.points import ApplyPointsChangeCommand +from schemas.domain.points import ApplyPointsChangeCommand class PointsRepository: diff --git a/backend/src/v1/points/service.py b/backend/src/v1/points/service.py index 09f80b5..8084111 100644 --- a/backend/src/v1/points/service.py +++ b/backend/src/v1/points/service.py @@ -8,7 +8,7 @@ from uuid import UUID, uuid4 from core.http.errors import ApiProblemError, problem_payload from schemas.domain.points import ConsumeLedgerMetadata, PointsChargeSnapshot from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType -from schemas.shared.points import ApplyPointsChangeCommand +from schemas.domain.points import ApplyPointsChangeCommand from v1.points.repository import PointsRepository RUN_POINTS_COST = 20 diff --git a/docs/protocols/divination/divination-run-protocol.md b/docs/protocols/divination/divination-run-protocol.md index a2284e4..0e1b520 100644 --- a/docs/protocols/divination/divination-run-protocol.md +++ b/docs/protocols/divination/divination-run-protocol.md @@ -160,7 +160,9 @@ During run streaming, backend emits standard AG-UI lifecycle events and two divi ### 2) `TEXT_MESSAGE_END` - Standard final answer event. -- Existing fields remain canonical: `sign_level`, `summary`, `conclusion`, `focus_points`, `advice`, `keywords`, `answer`. +- Existing fields remain canonical: `sign_level`, `conclusion`, `focus_points`, `advice`, `keywords`, `answer`. +- Language rule: `conclusion`, `focus_points`, `advice`, `keywords`, `answer` should follow user `ai_language` preference unless user explicitly requests otherwise. +- Canonical six-yao terms remain Chinese in protocol text (for example: 世爻、应爻、动爻、静爻、六亲、六神、伏神、月建、日辰、月破、日冲、空亡、五行旺衰). Frontend should combine: @@ -189,15 +191,11 @@ Frontend should combine: "agent_output": { "status": "success", "sign_level": "中上签", - "summary": "...", "conclusion": ["..."], "focus_points": ["..."], "advice": ["..."], "keywords": ["..."], "answer": "...", - "key_points": ["..."], - "result_type": "structured_payload", - "suggested_actions": ["..."], "divination_derived": { "binaryCode": "101001", "changedBinaryCode": "100001",