remdb 0.3.7__py3-none-any.whl → 0.3.133__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. rem/__init__.py +129 -2
  2. rem/agentic/README.md +76 -0
  3. rem/agentic/__init__.py +15 -0
  4. rem/agentic/agents/__init__.py +16 -2
  5. rem/agentic/agents/sse_simulator.py +502 -0
  6. rem/agentic/context.py +51 -25
  7. rem/agentic/llm_provider_models.py +301 -0
  8. rem/agentic/mcp/tool_wrapper.py +112 -17
  9. rem/agentic/otel/setup.py +93 -4
  10. rem/agentic/providers/phoenix.py +314 -132
  11. rem/agentic/providers/pydantic_ai.py +215 -26
  12. rem/agentic/schema.py +361 -21
  13. rem/agentic/tools/rem_tools.py +3 -3
  14. rem/api/README.md +238 -1
  15. rem/api/deps.py +255 -0
  16. rem/api/main.py +154 -37
  17. rem/api/mcp_router/resources.py +1 -1
  18. rem/api/mcp_router/server.py +26 -5
  19. rem/api/mcp_router/tools.py +465 -7
  20. rem/api/middleware/tracking.py +172 -0
  21. rem/api/routers/admin.py +494 -0
  22. rem/api/routers/auth.py +124 -0
  23. rem/api/routers/chat/completions.py +402 -20
  24. rem/api/routers/chat/models.py +88 -10
  25. rem/api/routers/chat/otel_utils.py +33 -0
  26. rem/api/routers/chat/sse_events.py +542 -0
  27. rem/api/routers/chat/streaming.py +642 -45
  28. rem/api/routers/dev.py +81 -0
  29. rem/api/routers/feedback.py +268 -0
  30. rem/api/routers/messages.py +473 -0
  31. rem/api/routers/models.py +78 -0
  32. rem/api/routers/query.py +360 -0
  33. rem/api/routers/shared_sessions.py +406 -0
  34. rem/auth/middleware.py +126 -27
  35. rem/cli/commands/README.md +237 -64
  36. rem/cli/commands/ask.py +13 -10
  37. rem/cli/commands/cluster.py +1808 -0
  38. rem/cli/commands/configure.py +5 -6
  39. rem/cli/commands/db.py +396 -139
  40. rem/cli/commands/experiments.py +469 -74
  41. rem/cli/commands/process.py +22 -15
  42. rem/cli/commands/scaffold.py +47 -0
  43. rem/cli/commands/schema.py +97 -50
  44. rem/cli/main.py +29 -6
  45. rem/config.py +10 -3
  46. rem/models/core/core_model.py +7 -1
  47. rem/models/core/experiment.py +54 -0
  48. rem/models/core/rem_query.py +5 -2
  49. rem/models/entities/__init__.py +21 -0
  50. rem/models/entities/domain_resource.py +38 -0
  51. rem/models/entities/feedback.py +123 -0
  52. rem/models/entities/message.py +30 -1
  53. rem/models/entities/session.py +83 -0
  54. rem/models/entities/shared_session.py +180 -0
  55. rem/models/entities/user.py +10 -3
  56. rem/registry.py +373 -0
  57. rem/schemas/agents/rem.yaml +7 -3
  58. rem/services/content/providers.py +92 -133
  59. rem/services/content/service.py +92 -20
  60. rem/services/dreaming/affinity_service.py +2 -16
  61. rem/services/dreaming/moment_service.py +2 -15
  62. rem/services/embeddings/api.py +24 -17
  63. rem/services/embeddings/worker.py +16 -16
  64. rem/services/phoenix/EXPERIMENT_DESIGN.md +3 -3
  65. rem/services/phoenix/client.py +302 -28
  66. rem/services/postgres/README.md +159 -15
  67. rem/services/postgres/__init__.py +2 -1
  68. rem/services/postgres/diff_service.py +531 -0
  69. rem/services/postgres/pydantic_to_sqlalchemy.py +427 -129
  70. rem/services/postgres/repository.py +132 -0
  71. rem/services/postgres/schema_generator.py +291 -9
  72. rem/services/postgres/service.py +6 -6
  73. rem/services/rate_limit.py +113 -0
  74. rem/services/rem/README.md +14 -0
  75. rem/services/rem/parser.py +44 -9
  76. rem/services/rem/service.py +36 -2
  77. rem/services/session/compression.py +24 -1
  78. rem/services/session/reload.py +1 -1
  79. rem/services/user_service.py +98 -0
  80. rem/settings.py +399 -29
  81. rem/sql/background_indexes.sql +21 -16
  82. rem/sql/migrations/001_install.sql +387 -54
  83. rem/sql/migrations/002_install_models.sql +2320 -393
  84. rem/sql/migrations/003_optional_extensions.sql +326 -0
  85. rem/sql/migrations/004_cache_system.sql +548 -0
  86. rem/utils/__init__.py +18 -0
  87. rem/utils/constants.py +97 -0
  88. rem/utils/date_utils.py +228 -0
  89. rem/utils/embeddings.py +17 -4
  90. rem/utils/files.py +167 -0
  91. rem/utils/mime_types.py +158 -0
  92. rem/utils/model_helpers.py +156 -1
  93. rem/utils/schema_loader.py +282 -35
  94. rem/utils/sql_paths.py +146 -0
  95. rem/utils/sql_types.py +3 -1
  96. rem/utils/vision.py +9 -14
  97. rem/workers/README.md +14 -14
  98. rem/workers/__init__.py +3 -1
  99. rem/workers/db_listener.py +579 -0
  100. rem/workers/db_maintainer.py +74 -0
  101. rem/workers/unlogged_maintainer.py +463 -0
  102. {remdb-0.3.7.dist-info → remdb-0.3.133.dist-info}/METADATA +460 -303
  103. {remdb-0.3.7.dist-info → remdb-0.3.133.dist-info}/RECORD +105 -74
  104. {remdb-0.3.7.dist-info → remdb-0.3.133.dist-info}/WHEEL +1 -1
  105. rem/sql/002_install_models.sql +0 -1068
  106. rem/sql/install_models.sql +0 -1038
  107. {remdb-0.3.7.dist-info → remdb-0.3.133.dist-info}/entry_points.txt +0 -0
@@ -15,6 +15,9 @@ Available Tools:
15
15
  - ask_rem_agent: Natural language to REM query conversion via agent
16
16
  - ingest_into_rem: Full file ingestion pipeline (read + store + parse + chunk)
17
17
  - read_resource: Access MCP resources (for Claude Desktop compatibility)
18
+ - register_metadata: Register response metadata for SSE MetadataEvent
19
+ - list_schema: List all schemas (tables, agents) in the database with row counts
20
+ - get_schema: Get detailed schema for a table (columns, types, indexes)
18
21
  """
19
22
 
20
23
  from functools import wraps
@@ -53,7 +56,7 @@ def init_services(postgres_service: PostgresService, rem_service: RemService):
53
56
  """
54
57
  _service_cache["postgres"] = postgres_service
55
58
  _service_cache["rem"] = rem_service
56
- logger.info("MCP tools initialized with service instances")
59
+ logger.debug("MCP tools initialized with service instances")
57
60
 
58
61
 
59
62
  async def get_rem_service() -> RemService:
@@ -79,7 +82,7 @@ async def get_rem_service() -> RemService:
79
82
  _service_cache["postgres"] = postgres_service
80
83
  _service_cache["rem"] = rem_service
81
84
 
82
- logger.info("MCP tools: lazy initialized services")
85
+ logger.debug("MCP tools: lazy initialized services")
83
86
  return rem_service
84
87
 
85
88
 
@@ -399,14 +402,14 @@ async def ask_rem_agent(
399
402
  )
400
403
 
401
404
  # Run agent (errors handled by decorator)
402
- logger.info(f"Running ask_rem agent for query: {query[:100]}...")
405
+ logger.debug(f"Running ask_rem agent for query: {query[:100]}...")
403
406
  result = await agent_runtime.run(query)
404
407
 
405
408
  # Extract output
406
409
  from rem.agentic.serialization import serialize_agent_result
407
410
  query_output = serialize_agent_result(result.output)
408
411
 
409
- logger.info("Agent execution completed successfully")
412
+ logger.debug("Agent execution completed successfully")
410
413
 
411
414
  return {
412
415
  "response": str(result.output),
@@ -422,6 +425,7 @@ async def ingest_into_rem(
422
425
  tags: list[str] | None = None,
423
426
  is_local_server: bool = False,
424
427
  user_id: str | None = None,
428
+ resource_type: str | None = None,
425
429
  ) -> dict[str, Any]:
426
430
  """
427
431
  Ingest file into REM, creating searchable resources and embeddings.
@@ -448,6 +452,11 @@ async def ingest_into_rem(
448
452
  tags: Optional tags for file
449
453
  is_local_server: True if running as local/stdio MCP server
450
454
  user_id: Optional user identifier (defaults to authenticated user or "default")
455
+ resource_type: Optional resource type for storing chunks (case-insensitive).
456
+ Supports flexible naming:
457
+ - "resource", "resources", "Resource" → Resource (default)
458
+ - "domain-resource", "domain_resource", "DomainResource",
459
+ "domain-resources" → DomainResource (curated internal knowledge)
451
460
 
452
461
  Returns:
453
462
  Dict with:
@@ -478,6 +487,13 @@ async def ingest_into_rem(
478
487
  file_uri="https://example.com/whitepaper.pdf",
479
488
  tags=["research", "whitepaper"]
480
489
  )
490
+
491
+ # Ingest as curated domain knowledge
492
+ ingest_into_rem(
493
+ file_uri="s3://bucket/internal/procedures.pdf",
494
+ resource_type="domain-resource",
495
+ category="procedures"
496
+ )
481
497
  """
482
498
  from ...services.content import ContentService
483
499
 
@@ -493,9 +509,10 @@ async def ingest_into_rem(
493
509
  category=category,
494
510
  tags=tags,
495
511
  is_local_server=is_local_server,
512
+ resource_type=resource_type,
496
513
  )
497
514
 
498
- logger.info(
515
+ logger.debug(
499
516
  f"MCP ingestion complete: {result['file_name']} "
500
517
  f"(status: {result['processing_status']}, "
501
518
  f"resources: {result['resources_created']})"
@@ -550,7 +567,7 @@ async def read_resource(uri: str) -> dict[str, Any]:
550
567
  # Check system status
551
568
  read_resource(uri="rem://status")
552
569
  """
553
- logger.info(f"📖 Reading resource: {uri}")
570
+ logger.debug(f"Reading resource: {uri}")
554
571
 
555
572
  # Import here to avoid circular dependency
556
573
  from .resources import load_resource
@@ -558,7 +575,7 @@ async def read_resource(uri: str) -> dict[str, Any]:
558
575
  # Load resource using the existing resource handler (errors handled by decorator)
559
576
  result = await load_resource(uri)
560
577
 
561
- logger.info(f"Resource loaded successfully: {uri}")
578
+ logger.debug(f"Resource loaded successfully: {uri}")
562
579
 
563
580
  # If result is already a dict, return it
564
581
  if isinstance(result, dict):
@@ -582,3 +599,444 @@ async def read_resource(uri: str) -> dict[str, Any]:
582
599
  "uri": uri,
583
600
  "data": {"content": result},
584
601
  }
602
+
603
+
604
+ async def register_metadata(
605
+ confidence: float | None = None,
606
+ references: list[str] | None = None,
607
+ sources: list[str] | None = None,
608
+ flags: list[str] | None = None,
609
+ # Session naming
610
+ session_name: str | None = None,
611
+ # Risk assessment fields (used by specialized agents)
612
+ risk_level: str | None = None,
613
+ risk_score: int | None = None,
614
+ risk_reasoning: str | None = None,
615
+ recommended_action: str | None = None,
616
+ # Generic extension - any additional key-value pairs
617
+ extra: dict[str, Any] | None = None,
618
+ ) -> dict[str, Any]:
619
+ """
620
+ Register response metadata to be emitted as an SSE MetadataEvent.
621
+
622
+ Call this tool BEFORE generating your final response to provide structured
623
+ metadata that will be sent to the client alongside your natural language output.
624
+ This allows you to stream conversational responses while still providing
625
+ machine-readable confidence scores, references, and other metadata.
626
+
627
+ **Design Pattern**: Agents can call this once before their final response to
628
+ register metadata that the streaming layer will emit as a MetadataEvent.
629
+ This decouples structured metadata from the response format.
630
+
631
+ Args:
632
+ confidence: Confidence score (0.0-1.0) for the response quality.
633
+ - 0.9-1.0: High confidence, answer is well-supported
634
+ - 0.7-0.9: Medium confidence, some uncertainty
635
+ - 0.5-0.7: Low confidence, significant gaps
636
+ - <0.5: Very uncertain, may need clarification
637
+ references: List of reference identifiers (file paths, document IDs,
638
+ entity labels) that support the response.
639
+ sources: List of source descriptions (e.g., "REM database",
640
+ "search results", "user context").
641
+ flags: Optional flags for the response (e.g., "needs_review",
642
+ "uncertain", "incomplete", "crisis_alert").
643
+
644
+ session_name: Short 1-3 phrase name describing the session topic.
645
+ Used by the UI to label conversations in the sidebar.
646
+ Examples: "Prescription Drug Questions", "AWS Setup Help",
647
+ "Python Code Review", "Travel Planning".
648
+
649
+ risk_level: Risk level indicator (e.g., "green", "orange", "red").
650
+ Used by mental health agents for C-SSRS style assessment.
651
+ risk_score: Numeric risk score (e.g., 0-6 for C-SSRS).
652
+ risk_reasoning: Brief explanation of risk assessment.
653
+ recommended_action: Suggested next steps based on assessment.
654
+
655
+ extra: Dict of arbitrary additional metadata. Use this for any
656
+ domain-specific fields not covered by the standard parameters.
657
+ Example: {"topics_detected": ["anxiety", "sleep"], "session_count": 5}
658
+
659
+ Returns:
660
+ Dict with:
661
+ - status: "success"
662
+ - _metadata_event: True (marker for streaming layer)
663
+ - All provided fields merged into response
664
+
665
+ Examples:
666
+ # High confidence answer with references
667
+ register_metadata(
668
+ confidence=0.95,
669
+ references=["sarah-chen", "q3-report-2024"],
670
+ sources=["REM database lookup"]
671
+ )
672
+
673
+ # Risk assessment example
674
+ register_metadata(
675
+ confidence=0.9,
676
+ risk_level="green",
677
+ risk_score=0,
678
+ risk_reasoning="No risk indicators detected in message",
679
+ sources=["mental_health_resources"]
680
+ )
681
+
682
+ # Orange risk with recommended action
683
+ register_metadata(
684
+ risk_level="orange",
685
+ risk_score=2,
686
+ risk_reasoning="Passive ideation detected - 'feeling hopeless'",
687
+ recommended_action="Schedule care team check-in within 24-48 hours",
688
+ flags=["care_team_alert"]
689
+ )
690
+
691
+ # Custom domain-specific metadata
692
+ register_metadata(
693
+ confidence=0.8,
694
+ extra={
695
+ "topics_detected": ["medication", "side_effects"],
696
+ "drug_mentioned": "sertraline",
697
+ "sentiment": "concerned"
698
+ }
699
+ )
700
+ """
701
+ logger.debug(
702
+ f"Registering metadata: confidence={confidence}, "
703
+ f"risk_level={risk_level}, refs={len(references or [])}, "
704
+ f"sources={len(sources or [])}"
705
+ )
706
+
707
+ result = {
708
+ "status": "success",
709
+ "_metadata_event": True, # Marker for streaming layer
710
+ "confidence": confidence,
711
+ "references": references,
712
+ "sources": sources,
713
+ "flags": flags,
714
+ }
715
+
716
+ # Add session name if provided
717
+ if session_name is not None:
718
+ result["session_name"] = session_name
719
+
720
+ # Add risk assessment fields if provided
721
+ if risk_level is not None:
722
+ result["risk_level"] = risk_level
723
+ if risk_score is not None:
724
+ result["risk_score"] = risk_score
725
+ if risk_reasoning is not None:
726
+ result["risk_reasoning"] = risk_reasoning
727
+ if recommended_action is not None:
728
+ result["recommended_action"] = recommended_action
729
+
730
+ # Merge any extra fields
731
+ if extra:
732
+ result["extra"] = extra
733
+
734
+ return result
735
+
736
+
737
+ @mcp_tool_error_handler
738
+ async def list_schema(
739
+ include_system: bool = False,
740
+ user_id: str | None = None,
741
+ ) -> dict[str, Any]:
742
+ """
743
+ List all schemas (tables) in the REM database.
744
+
745
+ Returns metadata about all available tables including their names,
746
+ row counts, and descriptions. Use this to discover what data is
747
+ available before constructing queries.
748
+
749
+ Args:
750
+ include_system: If True, include PostgreSQL system tables (pg_*, information_schema).
751
+ Default False shows only REM application tables.
752
+ user_id: Optional user identifier (defaults to authenticated user or "default")
753
+
754
+ Returns:
755
+ Dict with:
756
+ - status: "success" or "error"
757
+ - tables: List of table metadata dicts with:
758
+ - name: Table name
759
+ - schema: Schema name (usually "public")
760
+ - estimated_rows: Approximate row count
761
+ - description: Table comment if available
762
+
763
+ Examples:
764
+ # List all REM schemas
765
+ list_schema()
766
+
767
+ # Include system tables
768
+ list_schema(include_system=True)
769
+ """
770
+ rem_service = await get_rem_service()
771
+ user_id = AgentContext.get_user_id_or_default(user_id, source="list_schema")
772
+
773
+ # Query information_schema for tables
774
+ schema_filter = ""
775
+ if not include_system:
776
+ schema_filter = """
777
+ AND table_schema = 'public'
778
+ AND table_name NOT LIKE 'pg_%'
779
+ AND table_name NOT LIKE '_pg_%'
780
+ """
781
+
782
+ query = f"""
783
+ SELECT
784
+ t.table_schema,
785
+ t.table_name,
786
+ pg_catalog.obj_description(
787
+ (quote_ident(t.table_schema) || '.' || quote_ident(t.table_name))::regclass,
788
+ 'pg_class'
789
+ ) as description,
790
+ (
791
+ SELECT reltuples::bigint
792
+ FROM pg_class c
793
+ JOIN pg_namespace n ON n.oid = c.relnamespace
794
+ WHERE c.relname = t.table_name
795
+ AND n.nspname = t.table_schema
796
+ ) as estimated_rows
797
+ FROM information_schema.tables t
798
+ WHERE t.table_type = 'BASE TABLE'
799
+ {schema_filter}
800
+ ORDER BY t.table_schema, t.table_name
801
+ """
802
+
803
+ # Access postgres service directly from cache
804
+ postgres_service = _service_cache.get("postgres")
805
+ if not postgres_service:
806
+ postgres_service = rem_service._postgres
807
+
808
+ rows = await postgres_service.fetch(query)
809
+
810
+ tables = []
811
+ for row in rows:
812
+ tables.append({
813
+ "name": row["table_name"],
814
+ "schema": row["table_schema"],
815
+ "estimated_rows": int(row["estimated_rows"]) if row["estimated_rows"] else 0,
816
+ "description": row["description"],
817
+ })
818
+
819
+ logger.info(f"Listed {len(tables)} schemas for user {user_id}")
820
+
821
+ return {
822
+ "tables": tables,
823
+ "count": len(tables),
824
+ }
825
+
826
+
827
+ @mcp_tool_error_handler
828
+ async def get_schema(
829
+ table_name: str,
830
+ include_indexes: bool = True,
831
+ include_constraints: bool = True,
832
+ columns: list[str] | None = None,
833
+ user_id: str | None = None,
834
+ ) -> dict[str, Any]:
835
+ """
836
+ Get detailed schema information for a specific table.
837
+
838
+ Returns column definitions, data types, constraints, and indexes.
839
+ Use this to understand table structure before writing SQL queries.
840
+
841
+ Args:
842
+ table_name: Name of the table to inspect (e.g., "resources", "moments")
843
+ include_indexes: Include index information (default True)
844
+ include_constraints: Include constraint information (default True)
845
+ columns: Optional list of specific columns to return. If None, returns all columns.
846
+ user_id: Optional user identifier (defaults to authenticated user or "default")
847
+
848
+ Returns:
849
+ Dict with:
850
+ - status: "success" or "error"
851
+ - table_name: Name of the table
852
+ - columns: List of column definitions with:
853
+ - name: Column name
854
+ - type: PostgreSQL data type
855
+ - nullable: Whether NULL is allowed
856
+ - default: Default value if any
857
+ - description: Column comment if available
858
+ - indexes: List of indexes (if include_indexes=True)
859
+ - constraints: List of constraints (if include_constraints=True)
860
+ - primary_key: Primary key column(s)
861
+
862
+ Examples:
863
+ # Get full schema for resources table
864
+ get_schema(table_name="resources")
865
+
866
+ # Get only specific columns
867
+ get_schema(
868
+ table_name="resources",
869
+ columns=["id", "name", "created_at"]
870
+ )
871
+
872
+ # Get schema without indexes
873
+ get_schema(
874
+ table_name="moments",
875
+ include_indexes=False
876
+ )
877
+ """
878
+ rem_service = await get_rem_service()
879
+ user_id = AgentContext.get_user_id_or_default(user_id, source="get_schema")
880
+
881
+ # Access postgres service
882
+ postgres_service = _service_cache.get("postgres")
883
+ if not postgres_service:
884
+ postgres_service = rem_service._postgres
885
+
886
+ # Verify table exists
887
+ exists_query = """
888
+ SELECT EXISTS (
889
+ SELECT 1 FROM information_schema.tables
890
+ WHERE table_schema = 'public' AND table_name = $1
891
+ )
892
+ """
893
+ exists = await postgres_service.fetchval(exists_query, table_name)
894
+ if not exists:
895
+ return {
896
+ "status": "error",
897
+ "error": f"Table '{table_name}' not found in public schema",
898
+ }
899
+
900
+ # Get columns
901
+ columns_filter = ""
902
+ if columns:
903
+ placeholders = ", ".join(f"${i+2}" for i in range(len(columns)))
904
+ columns_filter = f"AND column_name IN ({placeholders})"
905
+
906
+ columns_query = f"""
907
+ SELECT
908
+ c.column_name,
909
+ c.data_type,
910
+ c.udt_name,
911
+ c.is_nullable,
912
+ c.column_default,
913
+ c.character_maximum_length,
914
+ c.numeric_precision,
915
+ pg_catalog.col_description(
916
+ (quote_ident(c.table_schema) || '.' || quote_ident(c.table_name))::regclass,
917
+ c.ordinal_position
918
+ ) as description
919
+ FROM information_schema.columns c
920
+ WHERE c.table_schema = 'public'
921
+ AND c.table_name = $1
922
+ {columns_filter}
923
+ ORDER BY c.ordinal_position
924
+ """
925
+
926
+ params = [table_name]
927
+ if columns:
928
+ params.extend(columns)
929
+
930
+ column_rows = await postgres_service.fetch(columns_query, *params)
931
+
932
+ column_defs = []
933
+ for row in column_rows:
934
+ # Build a more readable type string
935
+ data_type = row["data_type"]
936
+ if row["character_maximum_length"]:
937
+ data_type = f"{data_type}({row['character_maximum_length']})"
938
+ elif row["udt_name"] in ("int4", "int8", "float4", "float8"):
939
+ # Use common type names
940
+ type_map = {"int4": "integer", "int8": "bigint", "float4": "real", "float8": "double precision"}
941
+ data_type = type_map.get(row["udt_name"], data_type)
942
+ elif row["udt_name"] == "vector":
943
+ data_type = "vector"
944
+
945
+ column_defs.append({
946
+ "name": row["column_name"],
947
+ "type": data_type,
948
+ "nullable": row["is_nullable"] == "YES",
949
+ "default": row["column_default"],
950
+ "description": row["description"],
951
+ })
952
+
953
+ result = {
954
+ "table_name": table_name,
955
+ "columns": column_defs,
956
+ "column_count": len(column_defs),
957
+ }
958
+
959
+ # Get primary key
960
+ pk_query = """
961
+ SELECT a.attname as column_name
962
+ FROM pg_index i
963
+ JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
964
+ WHERE i.indrelid = $1::regclass
965
+ AND i.indisprimary
966
+ ORDER BY array_position(i.indkey, a.attnum)
967
+ """
968
+ pk_rows = await postgres_service.fetch(pk_query, table_name)
969
+ result["primary_key"] = [row["column_name"] for row in pk_rows]
970
+
971
+ # Get indexes
972
+ if include_indexes:
973
+ indexes_query = """
974
+ SELECT
975
+ i.relname as index_name,
976
+ am.amname as index_type,
977
+ ix.indisunique as is_unique,
978
+ ix.indisprimary as is_primary,
979
+ array_agg(a.attname ORDER BY array_position(ix.indkey, a.attnum)) as columns
980
+ FROM pg_index ix
981
+ JOIN pg_class i ON i.oid = ix.indexrelid
982
+ JOIN pg_class t ON t.oid = ix.indrelid
983
+ JOIN pg_am am ON am.oid = i.relam
984
+ JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
985
+ WHERE t.relname = $1
986
+ GROUP BY i.relname, am.amname, ix.indisunique, ix.indisprimary
987
+ ORDER BY i.relname
988
+ """
989
+ index_rows = await postgres_service.fetch(indexes_query, table_name)
990
+ result["indexes"] = [
991
+ {
992
+ "name": row["index_name"],
993
+ "type": row["index_type"],
994
+ "unique": row["is_unique"],
995
+ "primary": row["is_primary"],
996
+ "columns": row["columns"],
997
+ }
998
+ for row in index_rows
999
+ ]
1000
+
1001
+ # Get constraints
1002
+ if include_constraints:
1003
+ constraints_query = """
1004
+ SELECT
1005
+ con.conname as constraint_name,
1006
+ con.contype as constraint_type,
1007
+ array_agg(a.attname ORDER BY array_position(con.conkey, a.attnum)) as columns,
1008
+ pg_get_constraintdef(con.oid) as definition
1009
+ FROM pg_constraint con
1010
+ JOIN pg_class t ON t.oid = con.conrelid
1011
+ JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(con.conkey)
1012
+ WHERE t.relname = $1
1013
+ GROUP BY con.conname, con.contype, con.oid
1014
+ ORDER BY con.contype, con.conname
1015
+ """
1016
+ constraint_rows = await postgres_service.fetch(constraints_query, table_name)
1017
+
1018
+ # Map constraint types to readable names
1019
+ type_map = {
1020
+ "p": "PRIMARY KEY",
1021
+ "u": "UNIQUE",
1022
+ "f": "FOREIGN KEY",
1023
+ "c": "CHECK",
1024
+ "x": "EXCLUSION",
1025
+ }
1026
+
1027
+ result["constraints"] = []
1028
+ for row in constraint_rows:
1029
+ # contype is returned as bytes (char type), decode it
1030
+ con_type = row["constraint_type"]
1031
+ if isinstance(con_type, bytes):
1032
+ con_type = con_type.decode("utf-8")
1033
+ result["constraints"].append({
1034
+ "name": row["constraint_name"],
1035
+ "type": type_map.get(con_type, con_type),
1036
+ "columns": row["columns"],
1037
+ "definition": row["definition"],
1038
+ })
1039
+
1040
+ logger.info(f"Retrieved schema for table '{table_name}' with {len(column_defs)} columns")
1041
+
1042
+ return result