跳转至

数据库模式与实体关系

本页是 Chameleon 数据层的“总览入口”,用于回答三个问题:

  • 系统有哪些核心实体
  • 实体之间如何关联
  • 查询与检索如何落在索引和字段设计上

数据层总体设计

当前系统采用三层存储协作:

层级 技术 主要职责
关系型元数据层 PostgreSQL + SQLAlchemy 会话、消息、Agent、知识源、文档、摄取任务、手动 inspect run/result、文本块
向量索引层 Qdrant 存储向量与检索 payload
对象文件层 MinIO 存储原始上传文档

其中 PostgreSQL 是业务真相源,Qdrant 和 MinIO 分别承担检索性能与文件持久化。

ORM 代码组织

当前仓库继续保持 ai_service.storage.models 作为稳定兼容入口,同时通过 Alembic 演进业务 schema。当前 canonical head 为 fa12c4d6e8b9_add_agent_public_access_and_api_keys.py;它在既有 manual_inspection_runs / manual_inspection_resultsscheduled_tasks / scheduled_task_runs 等 domain 之上,继续把 Agent public access control 与 Fusion Agent API key inventory 纳入统一数据模型:

  • ai_service/storage/models.py 仍然是稳定兼容入口,供服务层、测试与 Alembic 继续导入
  • 具体 ORM 模型与数据访问函数已经拆分到 ai_service/storage/model_domains/
  • ai_service.storage.models 被导入时,仍会一次性加载全部 domain 模块并完成 Base.metadata 注册

管理员访问控制域

后台控制面现在新增 admin_access 领域模型,用于承载独立的管理员身份、RBAC、敏感操作验证和审计:

作用 关键字段
admin_users 管理员账号主表 emailusernamepassword_hashrole_codestatusis_protected
admin_permission_grants 子管理员权限授予表 admin_user_idpermission_codeis_activegranted_by_admin_id
admin_refresh_tokens 可撤销后台会话、refresh token 哈希,以及 access token 的服务端绑定锚点 admin_user_idtoken_hashexpires_atrevoked_at
admin_second_factor_challenges 敏感操作 challenge admin_user_idpurpose_codenonce_hashexpires_atused_at
admin_action_audits 管理员认证、写操作与拒绝访问审计 actor_admin_idtarget_admin_idaction_codemodule_coderesult_code
admin_system_settings 数据库托管的后台运行期设置 config_keyconfig_value_jsonupdated_by_admin_id

实现约束:

  • admin_users.emailadmin_users.username 唯一。
  • admin_users.role_code 仅允许 super_adminsub_admin
  • super_admin 的有效权限由服务端动态解析,不依赖 grant 记录。
  • admin_permission_grants(admin_user_id, permission_code) 建唯一约束,用于复用已有 grant 并切换 is_active
  • refresh token 与 challenge 在数据库中都只保存哈希值或一次性 nonce 哈希,不保存前端可直接复用的明文。
  • access token 不再是纯无状态信任;服务端会通过 admin_refresh_tokens 记录校验其绑定会话是否仍处于未撤销状态。
  • admin_action_audits 会记录认证失败、权限拒绝和敏感操作的前后快照,作为后台安全审计真相源。

当前 Alembic head 是 fa12c4d6e8b9_add_agent_public_access_and_api_keys.py

Fusion Public Access 扩展

Fusion public access 现在直接建模在统一 Agent 控制面,而不是额外维护第二套 public registry。

新字段 / 实体 作用
agents public_access_mode live public access policy,控制 admin_only / public_anonymous / public_api_key
agent_versions public_access_mode snapshot public access policy,确保 publish / rollback 后 public 行为与版本一致
agent_api_keys idagent_idkey_labelkey_prefixtoken_hashexpires_atlast_used_atcreated_by_admin_idrevoked_by_admin_idrevoked_at Fusion Agent 的 hash-only API key inventory
fusion_runs authenticated_agent_api_key_id 记录 public API key 鉴权成功后由哪个 Agent API key 发起了该 run

实现约束:

  • 历史 Agent 迁移后统一回填 public_access_mode='admin_only'
  • token_hash 唯一,明文 key 只在创建响应中返回一次
  • 单个 Fusion Agent 可以拥有多个未撤销 key,以支持无停机轮换
  • public Fusion run 成功使用 key 时,会把认证来源记录到 fusion_runs.authenticated_agent_api_key_id

ER 图

erDiagram
    AGENTS ||--o{ SESSIONS : "owns"
    SESSIONS ||--o{ MESSAGES : "contains"
    SESSIONS ||--o{ SESSION_TAKEOVER_EVENTS : "audits"
    SESSIONS ||--o{ CONVERSATION_TURNS : "observes"
    CONVERSATION_TURNS ||--o| TURN_CONTEXT_SNAPSHOTS : "captures"
    AGENTS ||--o{ AGENT_VERSIONS : "snapshots"
    AGENTS ||--o{ AGENT_EVALUATION_RUNS : "evaluates"
    AGENT_EVALUATION_RUNS ||--o{ AGENT_EVALUATION_RESULTS : "records"
    AGENTS ||--o{ SCHEDULED_TASKS : "schedules"
    AGENTS ||--o{ AGENT_KNOWLEDGE_LINKS : "mounts"
    KNOWLEDGE_SOURCES ||--o{ AGENT_KNOWLEDGE_LINKS : "mounted_by"
    KNOWLEDGE_SOURCES ||--o{ DOCUMENTS : "contains"
    KNOWLEDGE_SOURCES ||--o{ INGESTION_JOBS : "has_jobs"
    KNOWLEDGE_SOURCES ||--o{ MANUAL_INSPECTION_RUNS : "inspects"
    AGENTS ||--o{ INGESTION_JOBS : "scoped_jobs"
    KNOWLEDGE_SOURCES ||--o{ DOCUMENT_CHUNKS : "contains_chunks"
    AGENTS ||--o{ DOCUMENT_CHUNKS : "scoped_chunks"
    DOCUMENTS ||--o{ MANUAL_INSPECTION_RESULTS : "reported_in"
    MANUAL_INSPECTION_RUNS ||--o{ MANUAL_INSPECTION_RESULTS : "records"
    SESSIONS ||--o{ SCHEDULED_TASKS : "originates"
    CONVERSATION_TURNS ||--o{ SCHEDULED_TASKS : "creates"
    SCHEDULED_TASKS ||--o{ SCHEDULED_TASK_RUNS : "fires"
    SESSIONS ||--o{ SCHEDULED_TASK_RUNS : "targets"
    CONVERSATION_TURNS ||--o{ SCHEDULED_TASK_RUNS : "produces"

    AGENTS {
        string id PK
        string name
        string status
        string model_name
        string model_provider
        float model_temperature
        string orchestrator_key
        text model_routing_config_json
        text response_grounding_config_json
        bool human_takeover_enabled
        datetime created_at
        datetime updated_at
    }

    AGENT_VERSIONS {
        string id PK
        string agent_id FK
        int version_number
        string model_name
        string model_provider
        float model_temperature
        string orchestrator_key
        text model_routing_config_json
        text response_grounding_config_json
        bool human_takeover_enabled
        datetime created_at
    }

    AGENT_EVALUATION_RUNS {
        string id PK
        string agent_id FK
        string agent_version_id FK
        string dataset_id FK
        string status
        string model_name
        string model_provider
        float model_temperature
        text model_routing_config_json
        int items_total
        int items_done
        int generation_total_tokens
        int judge_total_tokens
        text generation_usage_breakdown_json
        text judge_usage_breakdown_json
        datetime started_at
        datetime completed_at
        datetime created_at
        datetime updated_at
    }

    AGENT_EVALUATION_RESULTS {
        string id PK
        string run_id FK
        string dataset_item_id FK
        string status
        int generation_total_tokens
        string generation_usage_source
        int judge_total_tokens
        string judge_usage_source
        int latency_ms
        datetime created_at
    }

    SCHEDULED_TASKS {
        string id PK
        string agent_id FK
        string source_session_id FK
        string source_turn_id FK
        string schedule_kind
        string timezone_name
        string target_mode
        string status
        string conflict_policy
        datetime next_run_at
        datetime last_run_at
        datetime last_successful_run_at
        string lease_token
        datetime worker_heartbeat_at
        int failure_count
        text schedule_payload_json
        text payload_json
        datetime created_at
        datetime updated_at
    }

    SCHEDULED_TASK_RUNS {
        string id PK
        string task_id FK
        string target_session_id FK
        string conversation_turn_id FK
        string status
        datetime scheduled_for
        datetime started_at
        datetime completed_at
        int attempt_count
        string lease_token
        datetime worker_heartbeat_at
        text error_message
        string trigger_event_name
        datetime created_at
        datetime updated_at
    }

    SESSIONS {
        string id PK
        string status
        string agent_id FK
        string agent_id_snapshot
        string agent_name_snapshot
        string takeover_owner_id_snapshot
        string takeover_owner_name_snapshot
        datetime takeover_started_at
        datetime takeover_released_at
        string model_name
        string model_provider
        float model_temperature
        datetime created_at
        datetime updated_at
    }

    MESSAGES {
        string id PK
        string session_id FK
        string role
        text content
        string sender_id_snapshot
        string sender_name_snapshot
        datetime created_at
    }

    SESSION_TAKEOVER_EVENTS {
        string id PK
        string session_id FK
        string event_type
        string operator_id_snapshot
        string operator_name_snapshot
        text payload_json
        datetime created_at
    }

    CONVERSATION_TURNS {
        string id PK
        string session_id FK
        string agent_id FK
        string agent_id_snapshot
        string agent_name_snapshot
        string trace_id
        int input_tokens
        int output_tokens
        int total_tokens
        string token_usage_source
        datetime created_at
    }

    TURN_CONTEXT_SNAPSHOTS {
        string id PK
        string turn_id FK
        text context_payload_json
        text rag_context_json
        text skill_context_json
        text mcp_context_json
    }

    KNOWLEDGE_SOURCES {
        string id PK
        string name
        string storage_type
        string status
        string default_chunking_strategy
        text default_chunking_params
        datetime created_at
        datetime updated_at
    }

    AGENT_KNOWLEDGE_LINKS {
        string id PK
        string agent_id FK
        string source_id FK
        bool is_active
        int priority
        datetime created_at
        datetime updated_at
    }

    DOCUMENTS {
        string id PK
        string source_id FK
        string filename
        string content_type
        int size_bytes
        string minio_object_key
        string status
        string vector_status
        int expected_point_count
        int actual_point_count
        datetime vector_verified_at
        text error_message
        text metadata_json
        datetime created_at
        datetime updated_at
    }

    MANUAL_INSPECTION_RUNS {
        string id PK
        string source_id FK
        string inspection_mode
        string status
        text document_filter_json
        string candidate_chunking_strategy
        text candidate_chunking_params_json
        int documents_total
        int documents_done
        text summary_json
        text error_message
        datetime created_at
        datetime updated_at
        datetime completed_at
    }

    MANUAL_INSPECTION_RESULTS {
        string id PK
        string inspection_run_id FK
        string document_id FK "nullable"
        string document_filename
        string document_processing_status
        string current_vector_status
        int expected_point_count
        int actual_point_count
        datetime vector_verified_at
        bool snapshot_available
        int preview_chunk_count
        int preview_chunk_delta
        string recommendation_code
        text recommendation_reason
        string error_code
        text error_message
        datetime created_at
    }

    INGESTION_JOBS {
        string id PK
        string source_id FK
        string agent_id FK
        string status
        string status_message
        int documents_total
        int documents_done
        int chunks_total
        int chunks_done
        int chunk_size
        int chunk_overlap
        string chunking_strategy
        text chunking_params
        string embedding_model
        text error_message
        datetime created_at
        datetime updated_at
    }

    DOCUMENT_CHUNKS {
        string id PK
        string source_id FK
        string agent_id FK
        string document_name
        int chunk_index
        text content
        text metadata_json
        string chunking_strategy
        datetime created_at
    }

实体分组说明

会话域

  • sessions:会话主表,保存会话状态、会话级模型覆盖参数,以及 Agent 的 live FK 与历史快照。
  • messages:会话消息流水,按 session_id 聚合历史上下文;人工回复会额外保存 sender 快照。
  • session_takeover_events:会话接管审计表,记录接管、强制接管、释放与人工回复事件。
  • conversation_turns:Turn 级观测表,记录每次用户输入/助手回复的 Agent、trace、模型信息,以及 turn 级 generation usage 汇总;同时通过 exclude_from_request_limits 区分该 turn 是否参与 request-limit 统计。
  • turn_context_snapshots:Turn 上下文快照表,保存 prompt/context/rag/skill/mcp 相关观测载荷。

Agent 与知识源域

  • agents:AI 角色配置,包含系统提示词、默认模型参数,以及 chat Agent 专用的 orchestrator_keymodel_routing_config_jsonresponse_grounding_config_jsonhuman_takeover_enabled、固定响应、MCP 响应配置与用量限制字段 max_concurrent_requests / max_total_requests / max_requests_per_day / max_total_tokens_daily / max_total_tokens_monthly / max_cost_usd_daily / max_cost_usd_monthly / max_tool_calls_total_per_request
  • agent_versions:Agent 不可变历史快照,保存包含 orchestrator_keymodel_routing_config_jsonresponse_grounding_config_jsonhuman_takeover_enabled、固定响应配置与成本控制字段在内的完整配置快照。
  • agent_request_leaseschat Agent 运行时并发占位表,记录每个进行中的请求 lease,并用于统计 active_request_count 与回收异常遗留的占位。
  • agent_evaluation_runs:评测运行快照,保存一次离线执行使用的 agent_version 绑定、主模型三元组、Judge runtime 快照、运行进度,以及 generation/judge 的 run 级 token rollup 和来源 breakdown。
  • agent_evaluation_results:评测样本结果账本,保存 prediction、规则分数、Judge 分数,以及 generation/judge 的 item 级 usage。
  • knowledge_sources:知识库逻辑分组,保存默认分块策略,以及 source_kind / managed_for_agent_id 等托管纠错来源标识。
  • agent_knowledge_links:多对多挂载关系,包含 priorityis_active
  • knowledge_corrections:管理员纠错真相源,保存原始问题/答案快照、标准答案、人工回复关联、托管文档与 ingestion job 状态。

文档摄取域

  • documents:上传文件元信息,关联 MinIO 对象键,并分别保存处理状态(status)与检索向量状态(vector_status)。
  • ingestion_jobs:异步摄取任务状态,记录进度计数与 status_message
  • document_chunks:文本分块结果,供向量化和检索回溯使用;同时保留 document_id 便于按托管纠错文档做清理和回溯。

手动 Inspect 域

  • manual_inspection_runs:source 级手动 inspect 运行账本,记录 inspect 模式、文档过滤范围、候选 chunking 配置、进度计数、summary 聚合、run 级错误和完成时间。
  • manual_inspection_results:每个 inspect run 下的文档级快照,记录文档处理状态、live vector 状态、preview chunk 统计、recommendation、snapshot_missing / baseline_unavailable / qdrant_unavailable 等显式异常码。document_id 是可空的 live 引用;当源文档被删除时,inspect 历史会保留并把该字段置空,只依赖 document_filename 等快照字段继续展示。

定时任务与执行审计域

  • scheduled_tasks:Conversation 内创建的 durable timer 定义,保存 schedule kind、timezone、target mode、conflict policy、下一次执行时间,以及 worker lease/heartbeat 状态。
  • scheduled_task_runs:每次触发尝试的执行账本,保存 queued/running/deferred/succeeded/failed/cancelled 状态、目标 session、conversation turn 回写、attempt 计数与错误信息;token 统计通过 conversation_turn_id 派生,不在本表重复存储。

关键索引与约束

名称 类型 作用
ix_agent_knowledge_unique 复合唯一索引 防止同一 Agent 重复挂载同一知识源
ix_documents_source_status 复合索引 加速按知识源和状态筛选文档
ix_manual_inspection_runs_source_created 复合索引 支撑 inspect history 按 source + 创建时间倒序读取
ix_manual_inspection_runs_source_status 复合索引 支撑按 source 和运行状态轮询 inspect runs
ix_manual_inspection_results_run_document_unique 复合唯一索引 防止同一 inspect run 为同一文档重复落盘结果
ix_document_chunks_agent_source 复合索引 加速按 Agent 与知识源过滤分块
ix_knowledge_corrections_source_status 复合索引 加速按托管纠错知识源和发布状态查询
ix_knowledge_corrections_agent_updated 复合索引 加速按 Agent 查看最近纠错记录
ix_agent_request_leases_agent_active 复合索引 加速按 Agent 统计未释放 request lease,支撑并发配额判断
ix_scheduled_tasks_status_next_run 复合索引 加速 dispatcher 按状态与 next_run_at claim due task
ix_scheduled_task_runs_status_heartbeat 复合索引 加速 supervisor 回收 heartbeat 超时的运行中任务
多个 index=True 外键索引 普通索引 支撑会话、任务、文档等关联查询

历史 Agent 身份保留

  • sessions.agent_idconversation_turns.agent_id 是指向 agents.id 的 live 外键,仅在 Agent 存在时保持非空。
  • sessions.agent_id_snapshot / agent_name_snapshotconversation_turns.agent_id_snapshot / agent_name_snapshot 用于保存历史身份。
  • sessions.takeover_owner_id_snapshot / takeover_owner_name_snapshot / takeover_started_at / takeover_released_at 用于保存当前或最近一次人工接管状态。
  • messages.sender_id_snapshot / sender_name_snapshot 用于持久化人工回复的操作者身份。
  • agents.orchestrator_keyagent_versions.orchestrator_key 用于保存 chat Agent 的具体聊天编排器选择;历史空值会在 API 响应与迁移回填中归一化到默认值 chameleon_chat_v1
  • agents.model_routing_config_jsonagent_versions.model_routing_config_json 用于保存 chat Agent 的角色化模型路由策略;角色槽位为空时,运行时会回退到主模型三元组。
  • agents.response_grounding_config_jsonagent_versions.response_grounding_config_json 用于保存 chat Agent 的最终回复防幻觉策略,并随 Agent 版本快照一起回滚。
  • agents.human_takeover_enabledagent_versions.human_takeover_enabled 用于保存会话是否允许人工接管,并随 Agent 版本快照一起回滚。
  • agents.hide_rag_source_filenameagent_versions.hide_rag_source_filename 用于保存聊天态 RAG 注入是否对模型隐藏 document_name,并随 Agent 版本快照一起回滚;后台 provenance 与审计追踪仍保留原始文件名。
  • agents.max_concurrent_requestsagent_versions.max_concurrent_requests 用于保存 chat Agent 的并发请求上限;空值表示不限流,并随 Agent 版本快照一起回滚。
  • agents.max_total_requestsagent_versions.max_total_requests 用于保存 chat Agent 的累计请求上限;空值表示不限量,并随 Agent 版本快照一起回滚。
  • agents.max_requests_per_dayagent_versions.max_requests_per_day 用于保存 chat Agent 当前 UTC 日的请求上限;空值表示不限制,并随 Agent 版本快照一起回滚。
  • agents.max_total_tokens_daily / max_total_tokens_monthly 及对应 agent_versions 字段用于保存 chat Agent 的 UTC 日 / 月 token 累计上限;统计来源为已持久化 conversation_turns.total_tokens
  • agents.max_cost_usd_daily / max_cost_usd_monthly 及对应 agent_versions 字段用于保存 chat Agent 的 UTC 日 / 月估算成本上限;估算基于模型单价与持久化的 input/output token。
  • agents.max_tool_calls_total_per_requestagent_versions.max_tool_calls_total_per_request 用于保存单次 chat 请求内 Skill + MCP 的总执行次数上限;空值表示不限,并随 Agent 版本快照一起回滚。
  • agent_evaluation_runs.model_routing_config_json 用于快照一次评测运行的角色化模型路由策略;若评测请求显式提供 model_* 覆盖,则该字段保持为空,因为本次执行已折叠为单模型路径。
  • agent_evaluation_runs.agent_version_id 指向 run 创建当时生成的不可变 agent_versions 快照;evaluation review 与后台 worker 都应优先基于该快照解释和复现实验配置,而不是读取后续被修改过的 live Agent。
  • agent_evaluation_runs.started_at 表示 Run 首次进入执行态的时间。
  • agent_evaluation_runs.completed_at 是 dataset evaluation run 的规范结束时间;系统不应再为同一语义新增 ended_at 或其他别名字段。
  • agents.message_type_response_config_jsonagent_versions.message_type_response_config_json 用于保存 chat Agent 的 rule/form/other 固定响应配置及其版本快照;每个 message_type 下既可保存默认 enabled + response_text 兜底文案,也可保存 message_key_responses[] 明细映射。
  • message_key_responses[] 中的 message_key 采用去首尾空格、大小写不敏感的精确匹配;同一 message_type 下归一化后不得重复。
  • conversation_turns.model_name/model_provider/model_temperature 仍然只保存最终回复阶段实际使用的模型;更细粒度的阶段路由历史保存在 turn_context_snapshots.context_payload_json 内的 model_routing
  • conversation_turns.input_tokens/output_tokens/total_tokens/token_usage_source 是新生成 AI turn 的 token 真相源;scheduled task 控制面必须从关联 turn 读取 usage,而不是在 scheduled_task_runs 上维护第二份副本。
  • conversation_turns.exclude_from_request_limits=true 仅表示该 turn 不参与 Request Limit 统计,不影响消息、turn 或 context snapshot 的可观测性保留。
  • agent_request_leases 只记录执行中的 live 请求,不承担历史访问量真相源;累计请求量仍以 conversation_turns 为准,但 request-limit 相关计数会排除 exclude_from_request_limits=true 的 turn。
  • agent_evaluation_results 会分别持久化 generation_*judge_* item 级 usage 列,成功和失败样本都遵循同一写时 fallback 规则。
  • agent_evaluation_runs 会分别持久化 generation_*judge_* run 级聚合列,以及 *_usage_breakdown_json 来源质量摘要。
  • conversation_turnsturn_context_snapshots 只覆盖 AI 生成回复;人工 takeover 期间的客户消息与人工回复只写入 messages / session_takeover_events,不会伪造 AI turn。
  • knowledge_corrections.manual_reply_message_id 只关联人工纠正答复消息,不会反向改写 conversation_turns 或 AI 原始消息内容。
  • knowledge_sources.source_kind="managed_correction"managed_for_agent_id 用于标记系统自动维护的纠错知识源;Admin UI 必须把它与普通上传知识源区分展示。
  • managed_correction 来源是服务端托管边界:普通上传、普通删除和通用 ingestion 入口都不应再允许人工直接改写。
  • 当 Agent 被删除时,系统会先回填 snapshot 字段,再把 live FK 置空;这样历史会话列表与 Turn 明细仍可展示原始 Agent。

托管纠错知识源约定

knowledge_sources

字段 说明
source_kind 逻辑来源类型;当前值包括 general_uploadmanaged_correction
managed_for_agent_id 当来源是系统托管纠错知识源时,指向所属 Agent

knowledge_corrections

字段 说明
source_session_id / source_turn_id 来源会话与 AI turn
manual_reply_message_id 当前会话纠正答复消息 id(若已发送)
document_id / ingestion_job_id 当前生效文档与最近一次发布任务;重发失败时 document_id 继续指向旧的已生效版本
status draft / publishing / published / failed
revision_number 同一 turn 的纠错修订号
original_user_message / original_ai_answer 不可变原始快照
corrected_question / corrected_answer 进入知识库检索的标准问答
correction_note 可选运营边界说明
last_error_message 最近一次发布失败细节

PostgreSQL 与 Qdrant 的映射

摄取时,系统会在 PostgreSQL 写入 document_chunks,并同步向量到 Qdrant。典型 payload 结构如下:

{
  "chunk_id": "uuid",
  "source_id": "knowledge_source_id",
  "document_id": "document_id",
  "document_name": "example.pdf",
  "chunk_index": 0,
  "agent_id": "agent_id"
}

检索阶段可按 source_idsource_idsagent_iddocument_id 做过滤,保证 Agent 级知识隔离。

当同一 Agent 同时挂载普通知识源与托管纠错知识源时,RAG 最终排序会额外考虑:

  1. source_kind == managed_correction 的来源优先;
  2. 其次按 agent_knowledge_links.priority
  3. 最后再看向量分数 / rerank 分数。

documents 中与向量一致性相关的关键字段:

  • status:处理链路状态,表示上传/处理中/处理完成/处理失败。
  • vector_status:检索链路状态,表示向量待核验、健康、需重建或核验失败。
  • expected_point_count:系统期望该文档在 Qdrant 中存在的 point 数。
  • actual_point_count:最近一次核验时在 Qdrant 中实际观测到的 point 数。
  • vector_verified_at:最近一次向量核验时间。

代码入口

  • ORM 模型:ai_service/storage/models.py
  • 会话与引擎:ai_service/utils/database.py
  • 向量服务:ai_service/storage/qdrant_client.py
  • 对象存储服务:ai_service/storage/minio_client.py