refactor(schema): 重构数据库模型和 schema,清理废弃表

This commit is contained in:
qzl
2026-04-07 18:43:34 +08:00
parent a65d041436
commit b18a205bf3
19 changed files with 101 additions and 426 deletions
@@ -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)")
+2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from .agent_chat_message import AgentChatMessage from .agent_chat_message import AgentChatMessage
from .agent_chat_session import AgentChatSession from .agent_chat_session import AgentChatSession
from .auth_user import AuthUser from .auth_user import AuthUser
from .invite_code import InviteCode
from .llm import Llm from .llm import Llm
from .llm_factory import LlmFactory from .llm_factory import LlmFactory
from .points_ledger import PointsLedger from .points_ledger import PointsLedger
@@ -14,6 +15,7 @@ __all__ = [
"AgentChatMessage", "AgentChatMessage",
"AgentChatSession", "AgentChatSession",
"AuthUser", "AuthUser",
"InviteCode",
"Llm", "Llm",
"LlmFactory", "LlmFactory",
"PointsLedger", "PointsLedger",
+1 -3
View File
@@ -21,6 +21,4 @@ class Llm(TimestampMixin, SoftDeleteMixin, Base):
nullable=False, nullable=False,
index=True, index=True,
) )
model_code: Mapped[str] = mapped_column( model_code: Mapped[str] = mapped_column(String(50), nullable=False, unique=True)
String(50), nullable=False, unique=True, index=True
)
+1 -3
View File
@@ -15,8 +15,6 @@ class LlmFactory(TimestampMixin, SoftDeleteMixin, Base):
id: Mapped[uuid.UUID] = mapped_column( id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
) )
name: Mapped[str] = mapped_column( name: Mapped[str] = mapped_column(String(50), nullable=False, unique=True)
String(50), nullable=False, unique=True, index=True
)
request_url: Mapped[str] = mapped_column(String(255), nullable=False) request_url: Mapped[str] = mapped_column(String(255), nullable=False)
avatar: Mapped[str | None] = mapped_column(Text, nullable=True) avatar: Mapped[str | None] = mapped_column(Text, nullable=True)
+5
View File
@@ -34,3 +34,8 @@ class Profile(TimestampMixin, SoftDeleteMixin, Base):
server_default=text("'{}'::jsonb"), server_default=text("'{}'::jsonb"),
default=dict, default=dict,
) )
referred_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("profiles.id", ondelete="SET NULL"),
nullable=True,
)
+3 -16
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from enum import Enum from enum import Enum
from typing import Any, Literal 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.agent.ui_hints import UiHintsPayload
from schemas.domain.divination import DerivedDivinationData from schemas.domain.divination import DerivedDivinationData
@@ -45,7 +45,6 @@ class WorkerAgentOutputLite(BaseModel):
status: RunStatus = RunStatus.SUCCESS status: RunStatus = RunStatus.SUCCESS
sign_level: Literal["上上签", "中上签", "中下签", "下下签"] sign_level: Literal["上上签", "中上签", "中下签", "下下签"]
summary: str = Field(min_length=1, max_length=300)
conclusion: list[str] = Field(min_length=1, max_length=6) conclusion: list[str] = Field(min_length=1, max_length=6)
focus_points: list[str] = Field(default_factory=list, max_length=6) focus_points: list[str] = Field(default_factory=list, max_length=6)
advice: list[str] = Field(min_length=1, 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) answer: str = Field(min_length=1, max_length=4000)
error: ErrorInfo | None = None 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): class WorkerAgentOutputRich(WorkerAgentOutputLite):
ui_hints: UiHintsPayload | None = None ui_hints: UiHintsPayload | None = None
@@ -75,6 +60,8 @@ class WorkerAgentOutputRich(WorkerAgentOutputLite):
class AgentOutput(WorkerAgentOutputRich): class AgentOutput(WorkerAgentOutputRich):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
divination_derived: DerivedDivinationData | None = None
WorkerAgentOutput = WorkerAgentOutputLite | WorkerAgentOutputRich WorkerAgentOutput = WorkerAgentOutputLite | WorkerAgentOutputRich
-124
View File
@@ -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
@@ -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
-81
View File
@@ -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
+50
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from decimal import Decimal from decimal import Decimal
from typing import Literal from typing import Literal
from uuid import UUID from uuid import UUID
@@ -7,6 +8,7 @@ from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, model_validator
from ..enums import ( from ..enums import (
PointsBizType,
PointsChangeType, PointsChangeType,
PointsOperatorType, PointsOperatorType,
) )
@@ -76,3 +78,51 @@ def parse_points_ledger_metadata(
if change_type == PointsChangeType.GRANT: if change_type == PointsChangeType.GRANT:
return GrantLedgerMetadata.model_validate(payload) return GrantLedgerMetadata.model_validate(payload)
return AdjustLedgerMetadata.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
-43
View File
@@ -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
-7
View File
@@ -1,7 +0,0 @@
from __future__ import annotations
from typing import Annotated
from pydantic import Field
TodoOrder = Annotated[int, Field(ge=0)]
-88
View File
@@ -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
+8 -3
View File
@@ -41,17 +41,22 @@ class PreferenceSettings(BaseModel):
return normalized return normalized
class NotificationSettings(BaseModel):
allow_notifications: bool = True
allow_vibration: bool = True
class ProfileSettingsV1(BaseModel): class ProfileSettingsV1(BaseModel):
version: Literal[1] = 1 version: Literal[1] = 1
preferences: PreferenceSettings = Field(default_factory=PreferenceSettings) preferences: PreferenceSettings = Field(default_factory=PreferenceSettings)
privacy: dict = Field(default_factory=dict) privacy: dict[str, object] = Field(default_factory=dict)
notification: dict = Field(default_factory=dict) notification: NotificationSettings = Field(default_factory=NotificationSettings)
ProfileSettingsUnion = ProfileSettingsV1 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 = dict(raw or {})
payload.setdefault("version", 1) payload.setdefault("version", 1)
return ProfileSettingsV1.model_validate(payload) return ProfileSettingsV1.model_validate(payload)
-4
View File
@@ -208,15 +208,11 @@ class HistoryAgentOutput(BaseModel):
status: Literal["success", "failed"] | None = None status: Literal["success", "failed"] | None = None
sign_level: Literal["上上签", "中上签", "中下签", "下下签"] | None = None sign_level: Literal["上上签", "中上签", "中下签", "下下签"] | None = None
summary: str | None = None
conclusion: list[str] = Field(default_factory=list) conclusion: list[str] = Field(default_factory=list)
focus_points: list[str] = Field(default_factory=list) focus_points: list[str] = Field(default_factory=list)
advice: list[str] = Field(default_factory=list) advice: list[str] = Field(default_factory=list)
keywords: list[str] = Field(default_factory=list) keywords: list[str] = Field(default_factory=list)
answer: str | None = None 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 divination_derived: DerivedDivinationData | None = None
+1 -36
View File
@@ -109,11 +109,7 @@ def _extract_worker_agent_output(
try: try:
agent_output = AgentOutput.model_validate(agent_output_data) agent_output = AgentOutput.model_validate(agent_output_data)
except Exception: except Exception:
normalized_payload = _normalize_agent_output_payload(agent_output_data) return None
try:
agent_output = AgentOutput.model_validate(normalized_payload)
except Exception:
return None
if not agent_output: if not agent_output:
return None return None
@@ -123,37 +119,6 @@ def _extract_worker_agent_output(
return payload or None 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: def mime_to_suffix(mime_type: str) -> str:
mapping = { mapping = {
"image/png": "png", "image/png": "png",
+1 -1
View File
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from models.points_ledger import PointsLedger from models.points_ledger import PointsLedger
from models.user_points import UserPoints from models.user_points import UserPoints
from schemas.shared.points import ApplyPointsChangeCommand from schemas.domain.points import ApplyPointsChangeCommand
class PointsRepository: class PointsRepository:
+1 -1
View File
@@ -8,7 +8,7 @@ from uuid import UUID, uuid4
from core.http.errors import ApiProblemError, problem_payload from core.http.errors import ApiProblemError, problem_payload
from schemas.domain.points import ConsumeLedgerMetadata, PointsChargeSnapshot from schemas.domain.points import ConsumeLedgerMetadata, PointsChargeSnapshot
from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType 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 from v1.points.repository import PointsRepository
RUN_POINTS_COST = 20 RUN_POINTS_COST = 20
@@ -160,7 +160,9 @@ During run streaming, backend emits standard AG-UI lifecycle events and two divi
### 2) `TEXT_MESSAGE_END` ### 2) `TEXT_MESSAGE_END`
- Standard final answer event. - 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: Frontend should combine:
@@ -189,15 +191,11 @@ Frontend should combine:
"agent_output": { "agent_output": {
"status": "success", "status": "success",
"sign_level": "中上签", "sign_level": "中上签",
"summary": "...",
"conclusion": ["..."], "conclusion": ["..."],
"focus_points": ["..."], "focus_points": ["..."],
"advice": ["..."], "advice": ["..."],
"keywords": ["..."], "keywords": ["..."],
"answer": "...", "answer": "...",
"key_points": ["..."],
"result_type": "structured_payload",
"suggested_actions": ["..."],
"divination_derived": { "divination_derived": {
"binaryCode": "101001", "binaryCode": "101001",
"changedBinaryCode": "100001", "changedBinaryCode": "100001",