"""create feedback center tables Revision ID: 20260326_create_feedback_center_tables Revises: 20260326_add_task_breakdown_payload_fields Create Date: 2026-03-26 """ from __future__ import annotations import json from typing import Any from alembic import op import sqlalchemy as sa try: from sqlalchemy.dialects.postgresql import JSONB except Exception: # pragma: no cover JSONB = sa.JSON revision = "20260326_create_feedback_center_tables" down_revision = "20260326_add_task_breakdown_payload_fields" branch_labels = None depends_on = None def _json_type(bind): return JSONB() if bind.dialect.name == "postgresql" else sa.JSON() def _table_exists(bind, table_name: str) -> bool: return sa.inspect(bind).has_table(table_name) def _index_exists(bind, table_name: str, index_name: str) -> bool: inspector = sa.inspect(bind) if not inspector.has_table(table_name): return False return any(str(item.get("name") or "") == index_name for item in inspector.get_indexes(table_name)) def _safe_int(value: Any) -> int | None: try: if value is None: return None return int(value) except (TypeError, ValueError): return None def _parse_json_if_needed(value: Any) -> Any: if value is None: return None if isinstance(value, (dict, list)): return value if isinstance(value, str): try: return json.loads(value) except (TypeError, ValueError): return value return value def _build_reason_candidates(*, source_type: str, field_name: str, before_value: Any, after_value: Any) -> list[dict[str, str]]: candidates: list[dict[str, str]] = [] field = str(field_name or "").strip().lower() source = str(source_type or "").strip().lower() def _add(code: str, label: str, description: str) -> None: if any(str(item.get("code") or "") == code for item in candidates): return candidates.append({"code": code, "label": label, "description": description}) if before_value not in (None, "", [], {}) and after_value in (None, "", [], {}): _add("incomplete_output", "输出信息不完整", "模型输出缺失关键信息字段") if "estimated" in field or "hours" in field: _add("effort_estimation_bias", "工时估算偏差", "任务工时估算与实际认知偏差较大") if "priority" in field: _add("priority_misalignment", "优先级错配", "优先级建议与业务紧急度不匹配") if "assignee" in field or "skill" in field: _add("capability_mismatch", "能力匹配偏差", "人员能力与任务推荐存在偏差") if "strategic" in field or source == "signal_feedback": _add("strategic_judgement_mismatch", "战略判断偏差", "战略属性判定与人工判断不一致") if source == "qa_feedback": _add("answer_incorrect", "答案不准确", "AI 输出与业务事实不一致") if not candidates: _add("manual_override", "人工覆盖", "由人工介入修改后形成的反馈记录") return candidates def _record_exists( bind, *, tenant_id: int, source_type: str, source_id: int, object_type: str, object_id: int, field_name: str, ) -> bool: row = bind.execute( sa.text( """ SELECT id FROM feedback_center_records WHERE tenant_id = :tenant_id AND source_type = :source_type AND source_id = :source_id AND object_type = :object_type AND object_id = :object_id AND field_name = :field_name LIMIT 1 """ ), { "tenant_id": int(tenant_id), "source_type": str(source_type), "source_id": int(source_id), "object_type": str(object_type), "object_id": int(object_id), "field_name": str(field_name), }, ).first() return row is not None def _insert_record(bind, payload: dict[str, Any]) -> None: bind.execute( sa.text( """ INSERT INTO feedback_center_records ( tenant_id, project_id, source_type, source_id, object_type, object_id, field_name, before_value, after_value, ai_snapshot, ai_confidence, reason_candidates, reason_code, reason_text, review_status, reviewed_by, reviewed_at, source_title, object_title, auto_assigned ) VALUES ( :tenant_id, :project_id, :source_type, :source_id, :object_type, :object_id, :field_name, :before_value, :after_value, :ai_snapshot, :ai_confidence, :reason_candidates, :reason_code, :reason_text, :review_status, :reviewed_by, :reviewed_at, :source_title, :object_title, :auto_assigned ) """ ), payload, ) def _backfill_from_qa_feedback(bind) -> None: if not _table_exists(bind, "qa_feedback") or not _table_exists(bind, "qa_records"): return rows = bind.execute( sa.text( """ SELECT f.id AS feedback_id, f.tenant_id AS tenant_id, f.qa_id AS qa_id, f.rating AS rating, f.helpfulness AS helpfulness, f.error_types AS error_types, f.correct_answer AS correct_answer, f.submitted_by AS submitted_by, f.created_at AS created_at, r.project_id AS project_id, r.question AS question, r.answer AS answer FROM qa_feedback f JOIN qa_records r ON r.id = f.qa_id """ ) ).mappings().all() for row in rows: tenant_id = _safe_int(row.get("tenant_id")) source_id = _safe_int(row.get("feedback_id")) object_id = _safe_int(row.get("qa_id")) if tenant_id is None or source_id is None or object_id is None: continue if _record_exists( bind, tenant_id=tenant_id, source_type="qa_feedback", source_id=source_id, object_type="qa_record", object_id=object_id, field_name="qa_feedback", ): continue before_value = {"answer": row.get("answer")} after_value = { "rating": row.get("rating"), "helpfulness": row.get("helpfulness"), "error_types": _parse_json_if_needed(row.get("error_types")), "correct_answer": row.get("correct_answer"), } candidates = _build_reason_candidates( source_type="qa_feedback", field_name="qa_feedback", before_value=before_value, after_value=after_value, ) _insert_record( bind, { "tenant_id": tenant_id, "project_id": _safe_int(row.get("project_id")), "source_type": "qa_feedback", "source_id": source_id, "object_type": "qa_record", "object_id": object_id, "field_name": "qa_feedback", "before_value": before_value, "after_value": after_value, "ai_snapshot": { "question": row.get("question"), "answer": row.get("answer"), }, "ai_confidence": None, "reason_candidates": candidates, "reason_code": candidates[0]["code"] if candidates else None, "reason_text": row.get("correct_answer"), "review_status": "reviewed", "reviewed_by": _safe_int(row.get("submitted_by")), "reviewed_at": row.get("created_at"), "source_title": str(row.get("question") or "")[:500] or None, "object_title": str(row.get("question") or "")[:500] or None, "auto_assigned": False, }, ) def _backfill_from_signal_feedback(bind) -> None: if not _table_exists(bind, "signal_feedbacks") or not _table_exists(bind, "signals"): return rows = bind.execute( sa.text( """ SELECT f.id AS feedback_id, f.tenant_id AS tenant_id, f.signal_id AS signal_id, f.was_strategic AS was_strategic, f.reason AS reason, f.ai_predicted_strategic AS ai_predicted_strategic, f.ai_confidence AS ai_confidence, f.user_id AS user_id, f.created_at AS created_at, s.matched_project_id AS project_id, s.title AS signal_title FROM signal_feedbacks f JOIN signals s ON s.id = f.signal_id """ ) ).mappings().all() for row in rows: tenant_id = _safe_int(row.get("tenant_id")) source_id = _safe_int(row.get("feedback_id")) object_id = _safe_int(row.get("signal_id")) if tenant_id is None or source_id is None or object_id is None: continue if _record_exists( bind, tenant_id=tenant_id, source_type="signal_feedback", source_id=source_id, object_type="signal", object_id=object_id, field_name="strategic_classification", ): continue before_value = { "ai_predicted_strategic": row.get("ai_predicted_strategic"), } after_value = { "was_strategic": row.get("was_strategic"), "reason": row.get("reason"), } candidates = _build_reason_candidates( source_type="signal_feedback", field_name="strategic_classification", before_value=before_value, after_value=after_value, ) _insert_record( bind, { "tenant_id": tenant_id, "project_id": _safe_int(row.get("project_id")), "source_type": "signal_feedback", "source_id": source_id, "object_type": "signal", "object_id": object_id, "field_name": "strategic_classification", "before_value": before_value, "after_value": after_value, "ai_snapshot": { "ai_predicted_strategic": row.get("ai_predicted_strategic"), "ai_confidence": row.get("ai_confidence"), }, "ai_confidence": row.get("ai_confidence"), "reason_candidates": candidates, "reason_code": candidates[0]["code"] if candidates else None, "reason_text": row.get("reason"), "review_status": "reviewed", "reviewed_by": _safe_int(row.get("user_id")), "reviewed_at": row.get("created_at"), "source_title": str(row.get("signal_title") or "")[:500] or None, "object_title": str(row.get("signal_title") or "")[:500] or None, "auto_assigned": False, }, ) def _backfill_from_task_overrides(bind) -> None: if not _table_exists(bind, "tasks"): return rows = bind.execute( sa.text( """ SELECT t.id AS task_id, t.tenant_id AS tenant_id, t.project_id AS project_id, t.title AS task_title, t.ai_breakdown_payload AS ai_breakdown_payload, t.manual_overrides AS manual_overrides, t.updated_at AS updated_at FROM tasks t WHERE t.manual_overrides IS NOT NULL """ ) ).mappings().all() for row in rows: tenant_id = _safe_int(row.get("tenant_id")) task_id = _safe_int(row.get("task_id")) if tenant_id is None or task_id is None: continue overrides = _parse_json_if_needed(row.get("manual_overrides")) ai_payload = _parse_json_if_needed(row.get("ai_breakdown_payload")) if not isinstance(overrides, dict) or not overrides: continue if not isinstance(ai_payload, dict): ai_payload = {} for field_name in sorted(overrides.keys()): if _record_exists( bind, tenant_id=tenant_id, source_type="task_override", source_id=task_id, object_type="task", object_id=task_id, field_name=str(field_name), ): continue before_value = ai_payload.get(field_name) after_value = overrides.get(field_name) candidates = _build_reason_candidates( source_type="task_override", field_name=str(field_name), before_value=before_value, after_value=after_value, ) _insert_record( bind, { "tenant_id": tenant_id, "project_id": _safe_int(row.get("project_id")), "source_type": "task_override", "source_id": task_id, "object_type": "task", "object_id": task_id, "field_name": str(field_name)[:128], "before_value": before_value, "after_value": after_value, "ai_snapshot": ai_payload, "ai_confidence": None, "reason_candidates": candidates, "reason_code": candidates[0]["code"] if candidates else None, "reason_text": None, "review_status": "reviewed", "reviewed_by": None, "reviewed_at": row.get("updated_at"), "source_title": str(row.get("task_title") or "")[:500] or None, "object_title": str(row.get("task_title") or "")[:500] or None, "auto_assigned": False, }, ) def upgrade() -> None: bind = op.get_bind() json_type = _json_type(bind) if not _table_exists(bind, "feedback_center_records"): op.create_table( "feedback_center_records", sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), sa.Column("tenant_id", sa.BigInteger(), nullable=False), sa.Column("project_id", sa.BigInteger(), nullable=True), sa.Column("source_type", sa.VARCHAR(length=32), nullable=False), sa.Column("source_id", sa.BigInteger(), nullable=False), sa.Column("object_type", sa.VARCHAR(length=32), nullable=False), sa.Column("object_id", sa.BigInteger(), nullable=False), sa.Column("field_name", sa.VARCHAR(length=128), nullable=False), sa.Column("before_value", json_type, nullable=True), sa.Column("after_value", json_type, nullable=True), sa.Column("ai_snapshot", json_type, nullable=True), sa.Column("ai_confidence", sa.Float(), nullable=True), sa.Column("reason_candidates", json_type, nullable=True), sa.Column("reason_code", sa.VARCHAR(length=64), nullable=True), sa.Column("reason_text", sa.Text(), nullable=True), sa.Column("review_status", sa.VARCHAR(length=16), nullable=False, server_default="pending"), sa.Column("reviewed_by", sa.BigInteger(), nullable=True), sa.Column("reviewed_at", sa.TIMESTAMP(), nullable=True), sa.Column("source_title", sa.VARCHAR(length=500), nullable=True), sa.Column("object_title", sa.VARCHAR(length=500), nullable=True), sa.Column("auto_assigned", sa.Boolean(), nullable=False, server_default=sa.text("false")), sa.Column("provider", sa.VARCHAR(length=32), nullable=True), sa.Column("model_name", sa.VARCHAR(length=128), nullable=True), sa.Column("prompt_version", sa.VARCHAR(length=64), nullable=True), sa.Column("schema_version", sa.VARCHAR(length=64), nullable=True), sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.text("now()")), sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.text("now()")), sa.UniqueConstraint( "tenant_id", "source_type", "source_id", "object_type", "object_id", "field_name", name="uq_feedback_center_records_source_object_field", ), ) if not _table_exists(bind, "feedback_center_record_revisions"): op.create_table( "feedback_center_record_revisions", sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), sa.Column("tenant_id", sa.BigInteger(), nullable=False), sa.Column("record_id", sa.Integer(), sa.ForeignKey("feedback_center_records.id", ondelete="CASCADE"), nullable=False), sa.Column("revision_no", sa.Integer(), nullable=False, server_default="1"), sa.Column("before_snapshot", json_type, nullable=False), sa.Column("after_snapshot", json_type, nullable=False), sa.Column("reason_code", sa.VARCHAR(length=64), nullable=True), sa.Column("reason_text", sa.Text(), nullable=True), sa.Column("review_status", sa.VARCHAR(length=16), nullable=False, server_default="reviewed"), sa.Column("reviewed_by", sa.BigInteger(), nullable=True), sa.Column("reviewed_at", sa.TIMESTAMP(), nullable=True), sa.Column("change_summary", sa.Text(), nullable=True), sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.text("now()")), sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.text("now()")), ) if not _index_exists(bind, "feedback_center_records", "ix_feedback_center_records_tenant_project_status"): op.create_index( "ix_feedback_center_records_tenant_project_status", "feedback_center_records", ["tenant_id", "project_id", "review_status"], unique=False, ) if not _index_exists(bind, "feedback_center_records", "ix_feedback_center_records_tenant_source"): op.create_index( "ix_feedback_center_records_tenant_source", "feedback_center_records", ["tenant_id", "source_type", "source_id"], unique=False, ) if not _index_exists(bind, "feedback_center_records", "ix_feedback_center_records_reason_code"): op.create_index( "ix_feedback_center_records_reason_code", "feedback_center_records", ["reason_code"], unique=False, ) if not _index_exists(bind, "feedback_center_record_revisions", "ix_feedback_center_record_revisions_tenant_record"): op.create_index( "ix_feedback_center_record_revisions_tenant_record", "feedback_center_record_revisions", ["tenant_id", "record_id"], unique=False, ) _backfill_from_qa_feedback(bind) _backfill_from_signal_feedback(bind) _backfill_from_task_overrides(bind) def downgrade() -> None: bind = op.get_bind() if _table_exists(bind, "feedback_center_record_revisions"): if _index_exists(bind, "feedback_center_record_revisions", "ix_feedback_center_record_revisions_tenant_record"): op.drop_index( "ix_feedback_center_record_revisions_tenant_record", table_name="feedback_center_record_revisions", ) op.drop_table("feedback_center_record_revisions") if _table_exists(bind, "feedback_center_records"): for index_name in ( "ix_feedback_center_records_reason_code", "ix_feedback_center_records_tenant_source", "ix_feedback_center_records_tenant_project_status", ): if _index_exists(bind, "feedback_center_records", index_name): op.drop_index(index_name, table_name="feedback_center_records") op.drop_table("feedback_center_records")