1014 lines
31 KiB
Markdown
1014 lines
31 KiB
Markdown
# Temporal.io + Graph RAG Integration for Workflows
|
|||
|
|
|
||
|
|
## Architecture
|
||
|
|
|
||
|
|
```
|
||
|
|
┌─────────────────────────────────┐
|
||
|
|
│ Workflow API Request │
|
||
|
|
│ POST /workflows/{id}/query │
|
||
|
|
└──────────────┬──────────────────┘
|
||
|
|
│ {query, search_type, relation_type, version}
|
||
|
|
▼
|
||
|
|
┌─────────────────────────────────┐
|
||
|
|
│ Temporal Workflow Orchestrator│
|
||
|
|
│ - Start execution │
|
||
|
|
│ - Route to activities │
|
||
|
|
│ - Aggregate results │
|
||
|
|
└──────────────┬──────────────────┘
|
||
|
|
│
|
||
|
|
┌──────┴──────┐
|
||
|
|
▼ ▼
|
||
|
|
┌────────────┐ ┌─────────────────┐
|
||
|
|
│ Activity 1 │ │ Activity 2 │
|
||
|
|
│ Retrieve │ │ Query Graph RAG │
|
||
|
|
│ Entities │ │ for Relations │
|
||
|
|
└────────────┘ └─────────────────┘
|
||
|
|
│ │
|
||
|
|
▼ ▼
|
||
|
|
┌────────────────────────────────┐
|
||
|
|
│ Memory API │
|
||
|
|
│ /memory/query/semantic │
|
||
|
|
│ /memory/entities/{id}/v.. │
|
||
|
|
└────────────────────────────────┘
|
||
|
|
│
|
||
|
|
▼
|
||
|
|
┌────────────────────────────────┐
|
||
|
|
│ Graph RAG (PostgreSQL) │
|
||
|
|
│ - workflow_relations │
|
||
|
|
│ - workflow_versions │
|
||
|
|
│ - relation_changes │
|
||
|
|
└────────────────────────────────┘
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 1. Temporal Workflow Definition
|
||
|
|
|
||
|
|
**File:** `statemachine/workflow_graph_query.go`
|
||
|
|
|
||
|
|
```go
|
||
|
|
package statemachine
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"time"
|
||
|
|
"go.temporal.io/sdk/workflow"
|
||
|
|
"github.com/rockliang/poimen/workflows/action"
|
||
|
|
"github.com/rockliang/poimen/workflows/pkg/db"
|
||
|
|
)
|
||
|
|
|
||
|
|
// WorkflowGraphQueryInput defines query parameters
|
||
|
|
type WorkflowGraphQueryInput struct {
|
||
|
|
WorkflowID string `json:"workflow_id"`
|
||
|
|
Query string `json:"query"`
|
||
|
|
SearchType string `json:"search_type"` // entities, edges, all
|
||
|
|
RelationType string `json:"relation_type"` // data-flow, dependency, etc
|
||
|
|
Version int `json:"version"` // canvas version
|
||
|
|
ConfidenceFloor float64 `json:"confidence_floor"`
|
||
|
|
TopK int `json:"top_k"`
|
||
|
|
FindPaths bool `json:"find_paths"`
|
||
|
|
TargetNodeID string `json:"target_node_id"`
|
||
|
|
MaxPathDepth int `json:"max_path_depth"`
|
||
|
|
RankingProfile string `json:"ranking_profile"` // default, recency, accuracy
|
||
|
|
IncludeReasoning bool `json:"include_reasoning"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// WorkflowGraphQueryOutput aggregates results
|
||
|
|
type WorkflowGraphQueryOutput struct {
|
||
|
|
WorkflowID string `json:"workflow_id"`
|
||
|
|
Query string `json:"query"`
|
||
|
|
Version int `json:"version"`
|
||
|
|
ExecutionTimeMs int64 `json:"execution_time_ms"`
|
||
|
|
Results []EdgeWithWording `json:"results"`
|
||
|
|
Paths []QueryPath `json:"paths"`
|
||
|
|
TotalCount int `json:"total_count"`
|
||
|
|
HasMore bool `json:"has_more"`
|
||
|
|
RankingProfile string `json:"ranking_profile"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type QueryPath struct {
|
||
|
|
SourceID string `json:"source_id"`
|
||
|
|
TargetID string `json:"target_id"`
|
||
|
|
PathCount int `json:"path_count"`
|
||
|
|
Distance int `json:"distance"`
|
||
|
|
Confidence float64 `json:"total_confidence"`
|
||
|
|
PathNodes []string `json:"node_ids"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// WorkflowGraphQuery is the main Temporal workflow
|
||
|
|
func WorkflowGraphQuery(ctx workflow.Context, input WorkflowGraphQueryInput) (WorkflowGraphQueryOutput, error) {
|
||
|
|
startTime := time.Now()
|
||
|
|
output := WorkflowGraphQueryOutput{
|
||
|
|
WorkflowID: input.WorkflowID,
|
||
|
|
Query: input.Query,
|
||
|
|
Version: input.Version,
|
||
|
|
RankingProfile: input.RankingProfile,
|
||
|
|
Results: []EdgeWithWording{},
|
||
|
|
}
|
||
|
|
|
||
|
|
// Activity options
|
||
|
|
opts := workflow.ActivityOptions{
|
||
|
|
StartToCloseTimeout: 120 * time.Second,
|
||
|
|
RetryPolicy: &temporal.RetryPolicy{
|
||
|
|
InitialInterval: 2 * time.Second,
|
||
|
|
BackoffCoefficient: 2.0,
|
||
|
|
MaxInterval: 10 * time.Second,
|
||
|
|
MaxAttempts: 3,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
ctx = workflow.WithActivityOptions(ctx, opts)
|
||
|
|
|
||
|
|
// Step 1: Fetch Canvas + Relations from Database
|
||
|
|
var canvasData action.CanvasWithRelationsData
|
||
|
|
err := workflow.ExecuteActivity(
|
||
|
|
ctx,
|
||
|
|
action.FetchCanvasRelationsActivity,
|
||
|
|
action.FetchCanvasRelationsInput{
|
||
|
|
WorkflowID: input.WorkflowID,
|
||
|
|
Version: input.Version,
|
||
|
|
},
|
||
|
|
).Get(ctx, &canvasData)
|
||
|
|
if err != nil {
|
||
|
|
return output, err
|
||
|
|
}
|
||
|
|
|
||
|
|
// Step 2: Query Graph RAG based on search_type
|
||
|
|
var graphResults action.GraphRAGQueryOutput
|
||
|
|
graphQuery := action.GraphRAGQueryInput{
|
||
|
|
WorkflowID: input.WorkflowID,
|
||
|
|
Query: input.Query,
|
||
|
|
SearchType: input.SearchType,
|
||
|
|
RelationType: input.RelationType,
|
||
|
|
ConfidenceFloor: input.ConfidenceFloor,
|
||
|
|
TopK: input.TopK,
|
||
|
|
RankingProfile: input.RankingProfile,
|
||
|
|
Canvas: canvasData,
|
||
|
|
}
|
||
|
|
|
||
|
|
err = workflow.ExecuteActivity(
|
||
|
|
ctx,
|
||
|
|
action.QueryGraphRAGActivity,
|
||
|
|
graphQuery,
|
||
|
|
).Get(ctx, &graphResults)
|
||
|
|
if err != nil {
|
||
|
|
return output, err
|
||
|
|
}
|
||
|
|
|
||
|
|
output.Results = graphResults.Edges
|
||
|
|
output.TotalCount = graphResults.TotalCount
|
||
|
|
output.HasMore = graphResults.HasMore
|
||
|
|
|
||
|
|
// Step 3: If find_paths requested, calculate paths
|
||
|
|
if input.FindPaths && input.TargetNodeID != "" {
|
||
|
|
var pathResults action.PathfindingOutput
|
||
|
|
err = workflow.ExecuteActivity(
|
||
|
|
ctx,
|
||
|
|
action.FindRelationPathsActivity,
|
||
|
|
action.FindRelationPathsInput{
|
||
|
|
WorkflowID: input.WorkflowID,
|
||
|
|
Version: input.Version,
|
||
|
|
SourceNodes: extractSourceNodes(graphResults.Edges),
|
||
|
|
TargetNodeID: input.TargetNodeID,
|
||
|
|
MaxDepth: input.MaxPathDepth,
|
||
|
|
Canvas: canvasData,
|
||
|
|
Relations: graphResults.Edges,
|
||
|
|
},
|
||
|
|
).Get(ctx, &pathResults)
|
||
|
|
if err != nil {
|
||
|
|
return output, err
|
||
|
|
}
|
||
|
|
output.Paths = pathResults.Paths
|
||
|
|
}
|
||
|
|
|
||
|
|
// Step 4: If reasoning requested, explain results
|
||
|
|
if input.IncludeReasoning {
|
||
|
|
var reasoning action.ReasoningOutput
|
||
|
|
err = workflow.ExecuteActivity(
|
||
|
|
ctx,
|
||
|
|
action.ExplainGraphResultsActivity,
|
||
|
|
action.ExplainGraphResultsInput{
|
||
|
|
Query: input.Query,
|
||
|
|
Results: output.Results,
|
||
|
|
Paths: output.Paths,
|
||
|
|
Confidence: calculateAverageConfidence(output.Results),
|
||
|
|
},
|
||
|
|
).Get(ctx, &reasoning)
|
||
|
|
if err != nil {
|
||
|
|
// Log but don't fail if reasoning fails
|
||
|
|
workflow.GetLogger(ctx).Warn("Reasoning failed", "error", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
output.ExecutionTimeMs = time.Since(startTime).Milliseconds()
|
||
|
|
return output, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func extractSourceNodes(edges []EdgeWithWording) []string {
|
||
|
|
nodeSet := make(map[string]bool)
|
||
|
|
for _, edge := range edges {
|
||
|
|
nodeSet[edge.Source] = true
|
||
|
|
}
|
||
|
|
var nodes []string
|
||
|
|
for node := range nodeSet {
|
||
|
|
nodes = append(nodes, node)
|
||
|
|
}
|
||
|
|
return nodes
|
||
|
|
}
|
||
|
|
|
||
|
|
func calculateAverageConfidence(edges []EdgeWithWording) float64 {
|
||
|
|
if len(edges) == 0 {
|
||
|
|
return 0.0
|
||
|
|
}
|
||
|
|
sum := 0.0
|
||
|
|
for _, edge := range edges {
|
||
|
|
sum += edge.RelationWording.Confidence
|
||
|
|
}
|
||
|
|
return sum / float64(len(edges))
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 2. Activity: Fetch Canvas + Relations
|
||
|
|
|
||
|
|
**File:** `action/fetch_canvas_relations.go`
|
||
|
|
|
||
|
|
```go
|
||
|
|
package action
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"fmt"
|
||
|
|
"github.com/rockliang/poimen/workflows/pkg/db"
|
||
|
|
)
|
||
|
|
|
||
|
|
type CanvasWithRelationsData struct {
|
||
|
|
WorkflowID string `json:"workflow_id"`
|
||
|
|
Version int `json:"version"`
|
||
|
|
Nodes []db.WorkflowNode `json:"nodes"`
|
||
|
|
Edges []EdgeWithWording `json:"edges"`
|
||
|
|
CreatedAt string `json:"created_at"`
|
||
|
|
Metadata map[string]interface{} `json:"metadata"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type FetchCanvasRelationsInput struct {
|
||
|
|
WorkflowID string `json:"workflow_id"`
|
||
|
|
Version int `json:"version"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// FetchCanvasRelationsActivity retrieves canvas + versioned relations
|
||
|
|
func FetchCanvasRelationsActivity(ctx context.Context, input FetchCanvasRelationsInput) (CanvasWithRelationsData, error) {
|
||
|
|
logger := newActivityLogger(ctx)
|
||
|
|
output := CanvasWithRelationsData{
|
||
|
|
WorkflowID: input.WorkflowID,
|
||
|
|
Version: input.Version,
|
||
|
|
Nodes: []db.WorkflowNode{},
|
||
|
|
Edges: []EdgeWithWording{},
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.logf("info", "Fetching canvas relations for workflow %s version %d", input.WorkflowID, input.Version)
|
||
|
|
|
||
|
|
// Get database connection (injected via Temporal context)
|
||
|
|
dbConn, err := getDBFromContext(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return output, fmt.Errorf("failed to get DB connection: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Fetch workflow version + canvas
|
||
|
|
var canvas db.Canvas
|
||
|
|
query := `
|
||
|
|
SELECT workflow_id, version, nodes, edges, created_at, metadata
|
||
|
|
FROM workflow_versions
|
||
|
|
WHERE workflow_id = $1 AND version = $2
|
||
|
|
`
|
||
|
|
rows, err := dbConn.Query(ctx, query, input.WorkflowID, input.Version)
|
||
|
|
if err != nil {
|
||
|
|
return output, fmt.Errorf("failed to query workflow_versions: %w", err)
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
if !rows.Next() {
|
||
|
|
return output, fmt.Errorf("workflow version not found: %s v%d", input.WorkflowID, input.Version)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Parse canvas JSONB
|
||
|
|
if err := rows.Scan(&canvas.WorkflowID, &canvas.Version, &canvas.Nodes, &canvas.Edges, &canvas.CreatedAt, &canvas.Metadata); err != nil {
|
||
|
|
return output, fmt.Errorf("failed to scan canvas: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
output.Nodes = canvas.Nodes
|
||
|
|
output.CreatedAt = canvas.CreatedAt.String()
|
||
|
|
|
||
|
|
// Fetch relations (edges with wording)
|
||
|
|
relQuery := `
|
||
|
|
SELECT id, source_node_id, target_node_id, relation_type, relation_label,
|
||
|
|
relation_wording, metadata, created_at
|
||
|
|
FROM workflow_relations
|
||
|
|
WHERE workflow_id = $1 AND version = $2
|
||
|
|
ORDER BY created_at
|
||
|
|
`
|
||
|
|
|
||
|
|
relRows, err := dbConn.Query(ctx, relQuery, input.WorkflowID, input.Version)
|
||
|
|
if err != nil {
|
||
|
|
return output, fmt.Errorf("failed to query workflow_relations: %w", err)
|
||
|
|
}
|
||
|
|
defer relRows.Close()
|
||
|
|
|
||
|
|
for relRows.Next() {
|
||
|
|
var edge EdgeWithWording
|
||
|
|
if err := relRows.Scan(
|
||
|
|
&edge.ID,
|
||
|
|
&edge.Source,
|
||
|
|
&edge.Target,
|
||
|
|
&edge.RelationType,
|
||
|
|
&edge.RelationLabel,
|
||
|
|
&edge.RelationWording,
|
||
|
|
&edge.Metadata,
|
||
|
|
&edge.CreatedAt,
|
||
|
|
); err != nil {
|
||
|
|
logger.logf("warn", "Failed to scan relation: %v", err)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
output.Edges = append(output.Edges, edge)
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.logf("info", "Fetched %d nodes, %d relations", len(output.Nodes), len(output.Edges))
|
||
|
|
return output, nil
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 3. Activity: Query Graph RAG
|
||
|
|
|
||
|
|
**File:** `action/query_graph_rag.go`
|
||
|
|
|
||
|
|
```go
|
||
|
|
package action
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net/http"
|
||
|
|
)
|
||
|
|
|
||
|
|
type GraphRAGQueryInput struct {
|
||
|
|
WorkflowID string `json:"workflow_id"`
|
||
|
|
Query string `json:"query"`
|
||
|
|
SearchType string `json:"search_type"` // entities, edges, all
|
||
|
|
RelationType string `json:"relation_type"`
|
||
|
|
ConfidenceFloor float64 `json:"confidence_floor"`
|
||
|
|
TopK int `json:"top_k"`
|
||
|
|
RankingProfile string `json:"ranking_profile"`
|
||
|
|
Canvas CanvasWithRelationsData `json:"canvas"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type GraphRAGQueryOutput struct {
|
||
|
|
WorkflowID string `json:"workflow_id"`
|
||
|
|
Query string `json:"query"`
|
||
|
|
Edges []EdgeWithWording `json:"results"`
|
||
|
|
TotalCount int `json:"total_count"`
|
||
|
|
HasMore bool `json:"has_more"`
|
||
|
|
ExecutionMs int64 `json:"execution_time_ms"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// QueryGraphRAGActivity calls Memory System unified query
|
||
|
|
func QueryGraphRAGActivity(ctx context.Context, input GraphRAGQueryInput) (GraphRAGQueryOutput, error) {
|
||
|
|
logger := newActivityLogger(ctx)
|
||
|
|
output := GraphRAGQueryOutput{
|
||
|
|
WorkflowID: input.WorkflowID,
|
||
|
|
Query: input.Query,
|
||
|
|
Edges: []EdgeWithWording{},
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.logf("info", "Querying Graph RAG: %s (type: %s, confidence: %.2f)", input.Query, input.SearchType, input.ConfidenceFloor)
|
||
|
|
|
||
|
|
// Build unified query for Memory System
|
||
|
|
memoryQuery := buildMemoryUnifiedQuery(input)
|
||
|
|
|
||
|
|
// Call Memory System /memory/query endpoint
|
||
|
|
payload, err := json.Marshal(memoryQuery)
|
||
|
|
if err != nil {
|
||
|
|
return output, fmt.Errorf("failed to marshal query: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get JWT token from context (set by worker)
|
||
|
|
token := ctx.Value("jwt_token").(string)
|
||
|
|
|
||
|
|
req, err := http.NewRequestWithContext(ctx, "POST", "http://localhost:8080/memory/query", bytes.NewReader(payload))
|
||
|
|
if err != nil {
|
||
|
|
return output, fmt.Errorf("failed to create request: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
||
|
|
req.Header.Set("Content-Type", "application/json")
|
||
|
|
|
||
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
||
|
|
resp, err := client.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
return output, fmt.Errorf("failed to query Memory System: %w", err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != 200 {
|
||
|
|
body, _ := io.ReadAll(resp.Body)
|
||
|
|
return output, fmt.Errorf("Memory System returned %d: %s", resp.StatusCode, string(body))
|
||
|
|
}
|
||
|
|
|
||
|
|
// Parse response
|
||
|
|
var memoryResp struct {
|
||
|
|
Results []map[string]interface{} `json:"results"`
|
||
|
|
TotalCount int `json:"total_count"`
|
||
|
|
HasMore bool `json:"has_more"`
|
||
|
|
ExecutionMs int64 `json:"execution_time_ms"`
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := json.NewDecoder(resp.Body).Decode(&memoryResp); err != nil {
|
||
|
|
return output, fmt.Errorf("failed to decode Memory response: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Map results to edges with wording
|
||
|
|
for _, result := range memoryResp.Results {
|
||
|
|
edge := EdgeWithWording{}
|
||
|
|
|
||
|
|
// Extract edge data from Memory result
|
||
|
|
if sourceID, ok := result["source_entity_id"].(string); ok {
|
||
|
|
edge.Source = sourceID
|
||
|
|
}
|
||
|
|
if targetID, ok := result["target_entity_id"].(string); ok {
|
||
|
|
edge.Target = targetID
|
||
|
|
}
|
||
|
|
if relType, ok := result["relation_type"].(string); ok {
|
||
|
|
edge.RelationType = relType
|
||
|
|
}
|
||
|
|
if fact, ok := result["fact"].(string); ok {
|
||
|
|
edge.RelationLabel = fact
|
||
|
|
}
|
||
|
|
if conf, ok := result["confidence"].(float64); ok {
|
||
|
|
edge.RelationWording.Confidence = conf
|
||
|
|
}
|
||
|
|
|
||
|
|
output.Edges = append(output.Edges, edge)
|
||
|
|
}
|
||
|
|
|
||
|
|
output.TotalCount = memoryResp.TotalCount
|
||
|
|
output.HasMore = memoryResp.HasMore
|
||
|
|
output.ExecutionMs = memoryResp.ExecutionMs
|
||
|
|
|
||
|
|
logger.logf("info", "Graph RAG returned %d edges", len(output.Edges))
|
||
|
|
return output, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// buildMemoryUnifiedQuery constructs Memory System unified query format
|
||
|
|
func buildMemoryUnifiedQuery(input GraphRAGQueryInput) map[string]interface{} {
|
||
|
|
query := map[string]interface{}{
|
||
|
|
"query": input.Query,
|
||
|
|
"search_type": "edges", // Default to edges for workflow relations
|
||
|
|
"confidence_floor": input.ConfidenceFloor,
|
||
|
|
"top_k": input.TopK,
|
||
|
|
"find_paths": input.RelationType != "",
|
||
|
|
"ranking_profile": input.RankingProfile,
|
||
|
|
"detect_communities": false,
|
||
|
|
"discover_facets": false,
|
||
|
|
}
|
||
|
|
|
||
|
|
// Add relation type filter if specified
|
||
|
|
if input.RelationType != "" {
|
||
|
|
if query["facet_filters"] == nil {
|
||
|
|
query["facet_filters"] = map[string]interface{}{}
|
||
|
|
}
|
||
|
|
filters := query["facet_filters"].(map[string]interface{})
|
||
|
|
filters["relation_type"] = input.RelationType
|
||
|
|
}
|
||
|
|
|
||
|
|
return query
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 4. Activity: Find Relation Paths
|
||
|
|
|
||
|
|
**File:** `action/find_relation_paths.go`
|
||
|
|
|
||
|
|
```go
|
||
|
|
package action
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"fmt"
|
||
|
|
)
|
||
|
|
|
||
|
|
type FindRelationPathsInput struct {
|
||
|
|
WorkflowID string `json:"workflow_id"`
|
||
|
|
Version int `json:"version"`
|
||
|
|
SourceNodes []string `json:"source_nodes"`
|
||
|
|
TargetNodeID string `json:"target_node_id"`
|
||
|
|
MaxDepth int `json:"max_depth"`
|
||
|
|
Canvas CanvasWithRelationsData `json:"canvas"`
|
||
|
|
Relations []EdgeWithWording `json:"relations"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type PathfindingOutput struct {
|
||
|
|
Paths []QueryPath `json:"paths"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// FindRelationPathsActivity computes shortest paths in relation graph
|
||
|
|
func FindRelationPathsActivity(ctx context.Context, input FindRelationPathsInput) (PathfindingOutput, error) {
|
||
|
|
logger := newActivityLogger(ctx)
|
||
|
|
output := PathfindingOutput{Paths: []QueryPath{}}
|
||
|
|
|
||
|
|
if input.TargetNodeID == "" {
|
||
|
|
return output, fmt.Errorf("target_node_id required for pathfinding")
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.logf("info", "Finding paths to %s (max depth: %d)", input.TargetNodeID, input.MaxDepth)
|
||
|
|
|
||
|
|
// Build adjacency list from relations
|
||
|
|
graph := buildRelationGraph(input.Relations)
|
||
|
|
|
||
|
|
// BFS from each source to target
|
||
|
|
for _, sourceNode := range input.SourceNodes {
|
||
|
|
if sourceNode == input.TargetNodeID {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
paths := bfsShortestPaths(graph, sourceNode, input.TargetNodeID, input.MaxDepth)
|
||
|
|
for _, path := range paths {
|
||
|
|
output.Paths = append(output.Paths, path)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.logf("info", "Found %d paths", len(output.Paths))
|
||
|
|
return output, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func buildRelationGraph(relations []EdgeWithWording) map[string][]string {
|
||
|
|
graph := make(map[string][]string)
|
||
|
|
for _, edge := range relations {
|
||
|
|
graph[edge.Source] = append(graph[edge.Source], edge.Target)
|
||
|
|
}
|
||
|
|
return graph
|
||
|
|
}
|
||
|
|
|
||
|
|
func bfsShortestPaths(graph map[string][]string, source, target string, maxDepth int) []QueryPath {
|
||
|
|
var paths []QueryPath
|
||
|
|
|
||
|
|
queue := [][]string{{source}}
|
||
|
|
visited := make(map[string]bool)
|
||
|
|
visited[source] = true
|
||
|
|
|
||
|
|
for len(queue) > 0 {
|
||
|
|
path := queue[0]
|
||
|
|
queue = queue[1:]
|
||
|
|
|
||
|
|
if len(path) > maxDepth {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
current := path[len(path)-1]
|
||
|
|
if current == target {
|
||
|
|
paths = append(paths, QueryPath{
|
||
|
|
SourceID: source,
|
||
|
|
TargetID: target,
|
||
|
|
Distance: len(path) - 1,
|
||
|
|
PathCount: len(paths) + 1,
|
||
|
|
PathNodes: path,
|
||
|
|
})
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, neighbor := range graph[current] {
|
||
|
|
newPath := append([]string{}, path...)
|
||
|
|
newPath = append(newPath, neighbor)
|
||
|
|
queue = append(queue, newPath)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return paths
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 5. API Handler: Wire Everything Together
|
||
|
|
|
||
|
|
**File:** `internal/api/workflows_graph_query.go`
|
||
|
|
|
||
|
|
```go
|
||
|
|
package api
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"net/http"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"go.temporal.io/sdk/client"
|
||
|
|
"github.com/rockliang/poimen/workflows/statemachine"
|
||
|
|
)
|
||
|
|
|
||
|
|
// QueryWorkflowGraph handles POST /workflows/{id}/query
|
||
|
|
func (s *Server) QueryWorkflowGraph(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if r.Method != http.MethodPost {
|
||
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
workflowID := r.PathValue("id")
|
||
|
|
if workflowID == "" {
|
||
|
|
http.Error(w, "Missing workflow ID", http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Parse request
|
||
|
|
var input statemachine.WorkflowGraphQueryInput
|
||
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
input.WorkflowID = workflowID
|
||
|
|
|
||
|
|
// Set defaults
|
||
|
|
if input.SearchType == "" {
|
||
|
|
input.SearchType = "edges"
|
||
|
|
}
|
||
|
|
if input.TopK == 0 {
|
||
|
|
input.TopK = 10
|
||
|
|
}
|
||
|
|
if input.ConfidenceFloor == 0 {
|
||
|
|
input.ConfidenceFloor = 0.5
|
||
|
|
}
|
||
|
|
if input.MaxPathDepth == 0 {
|
||
|
|
input.MaxPathDepth = 3
|
||
|
|
}
|
||
|
|
if input.RankingProfile == "" {
|
||
|
|
input.RankingProfile = "default"
|
||
|
|
}
|
||
|
|
|
||
|
|
// Start Temporal workflow
|
||
|
|
temporalClient := s.TemporalClient // injected in NewServer
|
||
|
|
|
||
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
|
||
|
|
we, err := temporalClient.ExecuteWorkflow(
|
||
|
|
ctx,
|
||
|
|
client.StartWorkflowOptions{
|
||
|
|
ID: workflowID + "-query-" + time.Now().Format("20060102150405"),
|
||
|
|
TaskQueue: "workflow-tasks",
|
||
|
|
},
|
||
|
|
statemachine.WorkflowGraphQuery,
|
||
|
|
input,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
http.Error(w, "Failed to start workflow", http.StatusInternalServerError)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Wait for result
|
||
|
|
var result statemachine.WorkflowGraphQueryOutput
|
||
|
|
if err := we.Get(ctx, &result); err != nil {
|
||
|
|
http.Error(w, "Workflow failed", http.StatusInternalServerError)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Return result
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
w.WriteHeader(http.StatusOK)
|
||
|
|
json.NewEncoder(w).Encode(result)
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetWorkflowRelationVersions handles GET /workflows/{id}/relations/{edge_id}/versions
|
||
|
|
func (s *Server) GetWorkflowRelationVersions(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if r.Method != http.MethodGet {
|
||
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
workflowID := r.PathValue("id")
|
||
|
|
edgeID := r.PathValue("edge_id")
|
||
|
|
|
||
|
|
if workflowID == "" || edgeID == "" {
|
||
|
|
http.Error(w, "Missing workflow or edge ID", http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Query database for edge versions
|
||
|
|
query := `
|
||
|
|
SELECT version_num, operation, snapshot, changed_at, changed_by, fields_changed
|
||
|
|
FROM workflow_relation_versions
|
||
|
|
WHERE workflow_id = $1 AND edge_id = $2
|
||
|
|
ORDER BY version_num
|
||
|
|
`
|
||
|
|
|
||
|
|
rows, err := s.DB.Query(r.Context(), query, workflowID, edgeID)
|
||
|
|
if err != nil {
|
||
|
|
http.Error(w, "Failed to query versions", http.StatusInternalServerError)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
type Version struct {
|
||
|
|
VersionNum int `json:"version_num"`
|
||
|
|
Operation string `json:"operation"`
|
||
|
|
Snapshot interface{} `json:"snapshot"`
|
||
|
|
ChangedAt string `json:"changed_at"`
|
||
|
|
ChangedBy string `json:"changed_by"`
|
||
|
|
FieldsChanged []string `json:"fields_changed"`
|
||
|
|
}
|
||
|
|
|
||
|
|
versions := []Version{}
|
||
|
|
for rows.Next() {
|
||
|
|
v := Version{}
|
||
|
|
if err := rows.Scan(&v.VersionNum, &v.Operation, &v.Snapshot, &v.ChangedAt, &v.ChangedBy, &v.FieldsChanged); err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
versions = append(versions, v)
|
||
|
|
}
|
||
|
|
|
||
|
|
response := map[string]interface{}{
|
||
|
|
"edge_id": edgeID,
|
||
|
|
"workflow_id": workflowID,
|
||
|
|
"versions": versions,
|
||
|
|
"total_versions": len(versions),
|
||
|
|
"current_version": len(versions),
|
||
|
|
}
|
||
|
|
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
json.NewEncoder(w).Encode(response)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 6. Database Schema for Graph RAG
|
||
|
|
|
||
|
|
**File:** `migrations/004_graph_rag_relations.sql`
|
||
|
|
|
||
|
|
```sql
|
||
|
|
-- Versioned relations with wording
|
||
|
|
CREATE TABLE workflow_relations (
|
||
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
|
|
workflow_id UUID NOT NULL,
|
||
|
|
version INT NOT NULL,
|
||
|
|
source_node_id VARCHAR(255) NOT NULL,
|
||
|
|
target_node_id VARCHAR(255) NOT NULL,
|
||
|
|
relation_type VARCHAR(100), -- data-flow, dependency, conditional, parallel
|
||
|
|
relation_label TEXT,
|
||
|
|
relation_wording JSONB, -- {verb, source_output, target_input, confidence, etc}
|
||
|
|
metadata JSONB,
|
||
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
||
|
|
|
||
|
|
FOREIGN KEY (workflow_id, version) REFERENCES workflow_versions(workflow_id, version),
|
||
|
|
INDEX (workflow_id, version),
|
||
|
|
INDEX (relation_type),
|
||
|
|
UNIQUE (workflow_id, version, source_node_id, target_node_id)
|
||
|
|
);
|
||
|
|
|
||
|
|
-- Relation change history for versioning
|
||
|
|
CREATE TABLE workflow_relation_versions (
|
||
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
|
|
workflow_id UUID NOT NULL,
|
||
|
|
edge_id UUID NOT NULL REFERENCES workflow_relations(id),
|
||
|
|
version_num INT NOT NULL,
|
||
|
|
operation VARCHAR(50), -- CREATE, UPDATE, DELETE
|
||
|
|
snapshot JSONB, -- Full relation state at this version
|
||
|
|
changed_at TIMESTAMP DEFAULT NOW(),
|
||
|
|
changed_by VARCHAR(255),
|
||
|
|
fields_changed TEXT[],
|
||
|
|
|
||
|
|
INDEX (workflow_id, version_num),
|
||
|
|
UNIQUE (workflow_id, edge_id, version_num)
|
||
|
|
);
|
||
|
|
|
||
|
|
-- Graph RAG indexing metadata
|
||
|
|
CREATE TABLE workflow_rag_index (
|
||
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
|
|
workflow_id UUID NOT NULL,
|
||
|
|
version INT NOT NULL,
|
||
|
|
indexed_entities JSONB, -- {node_ids, count}
|
||
|
|
indexed_edges JSONB, -- {edge_ids, count}
|
||
|
|
index_status VARCHAR(50), -- indexed, pending, failed
|
||
|
|
last_indexed_at TIMESTAMP,
|
||
|
|
embedding_model VARCHAR(100),
|
||
|
|
|
||
|
|
INDEX (workflow_id, version)
|
||
|
|
);
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 7. Worker Configuration
|
||
|
|
|
||
|
|
**File:** `cmd/server/main.go` (additions)
|
||
|
|
|
||
|
|
```go
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"go.temporal.io/sdk/client"
|
||
|
|
"go.temporal.io/sdk/worker"
|
||
|
|
"github.com/rockliang/poimen/workflows/statemachine"
|
||
|
|
"github.com/rockliang/poimen/workflows/action"
|
||
|
|
)
|
||
|
|
|
||
|
|
func main() {
|
||
|
|
// ... existing setup ...
|
||
|
|
|
||
|
|
// Temporal client
|
||
|
|
c, err := client.Dial(client.Options{
|
||
|
|
HostPort: os.Getenv("TEMPORAL_HOST_PORT"), // localhost:7233
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
log.Fatal(err)
|
||
|
|
}
|
||
|
|
defer c.Close()
|
||
|
|
|
||
|
|
// Worker
|
||
|
|
w := worker.New(c, "workflow-tasks", worker.Options{})
|
||
|
|
|
||
|
|
// Register workflows
|
||
|
|
w.RegisterWorkflow(statemachine.WorkflowGraphQuery)
|
||
|
|
|
||
|
|
// Register activities with DB connection in context
|
||
|
|
w.RegisterActivityWithOptions(
|
||
|
|
func(ctx context.Context, input interface{}) (interface{}, error) {
|
||
|
|
// Inject DB into context
|
||
|
|
ctx = context.WithValue(ctx, "db", dbConn)
|
||
|
|
ctx = context.WithValue(ctx, "jwt_token", os.Getenv("JWT_TOKEN"))
|
||
|
|
return nil, nil
|
||
|
|
},
|
||
|
|
activity.RegisterOptions{Name: "setup"},
|
||
|
|
)
|
||
|
|
|
||
|
|
w.RegisterActivity(action.FetchCanvasRelationsActivity)
|
||
|
|
w.RegisterActivity(action.QueryGraphRAGActivity)
|
||
|
|
w.RegisterActivity(action.FindRelationPathsActivity)
|
||
|
|
w.RegisterActivity(action.ExplainGraphResultsActivity)
|
||
|
|
|
||
|
|
// Start worker
|
||
|
|
if err := w.Start(); err != nil {
|
||
|
|
log.Fatal(err)
|
||
|
|
}
|
||
|
|
defer w.Stop()
|
||
|
|
|
||
|
|
// Register API handler
|
||
|
|
server := api.NewServer(dbConn, c)
|
||
|
|
http.HandleFunc("POST /workflows/{id}/query", server.QueryWorkflowGraph)
|
||
|
|
http.HandleFunc("GET /workflows/{id}/relations/{edge_id}/versions", server.GetWorkflowRelationVersions)
|
||
|
|
|
||
|
|
log.Fatal(http.ListenAndServe(":8081", nil))
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 8. Request/Response Flow Example
|
||
|
|
|
||
|
|
### Request
|
||
|
|
```bash
|
||
|
|
curl -X POST http://localhost:8081/workflows/workflow-1/query \
|
||
|
|
-H "Content-Type: application/json" \
|
||
|
|
-d '{
|
||
|
|
"query": "how does code analysis flow into security scanning",
|
||
|
|
"search_type": "edges",
|
||
|
|
"relation_type": "data-flow",
|
||
|
|
"version": 3,
|
||
|
|
"confidence_floor": 0.7,
|
||
|
|
"top_k": 10,
|
||
|
|
"find_paths": true,
|
||
|
|
"target_node_id": "security-scan-1",
|
||
|
|
"max_path_depth": 3,
|
||
|
|
"ranking_profile": "default",
|
||
|
|
"include_reasoning": true
|
||
|
|
}'
|
||
|
|
```
|
||
|
|
|
||
|
|
### Execution Flow (Temporal)
|
||
|
|
|
||
|
|
1. **API Handler** → Starts `WorkflowGraphQuery` workflow
|
||
|
|
2. **Workflow** → Calls `FetchCanvasRelationsActivity`
|
||
|
|
- Query DB: `SELECT * FROM workflow_versions WHERE workflow_id=? AND version=?`
|
||
|
|
- Query DB: `SELECT * FROM workflow_relations WHERE workflow_id=? AND version=?`
|
||
|
|
- Return: Canvas + Relations data
|
||
|
|
|
||
|
|
3. **Workflow** → Calls `QueryGraphRAGActivity`
|
||
|
|
- Build unified query for Memory System
|
||
|
|
- POST to `/memory/query` with edges search
|
||
|
|
- Return: Ranked edges with wording
|
||
|
|
|
||
|
|
4. **Workflow** → If find_paths, calls `FindRelationPathsActivity`
|
||
|
|
- BFS pathfinding in relation graph
|
||
|
|
- Return: Query paths from sources to target
|
||
|
|
|
||
|
|
5. **Workflow** → If reasoning, calls `ExplainGraphResultsActivity`
|
||
|
|
- LLM explains why results match query
|
||
|
|
- Return: Reasoning narrative
|
||
|
|
|
||
|
|
6. **Workflow** → Aggregates results, returns to API
|
||
|
|
7. **API Handler** → Returns 200 + JSON
|
||
|
|
|
||
|
|
### Response
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"workflow_id": "workflow-1",
|
||
|
|
"query": "how does code analysis flow into security scanning",
|
||
|
|
"version": 3,
|
||
|
|
"execution_time_ms": 245,
|
||
|
|
"results": [
|
||
|
|
{
|
||
|
|
"id": "edge_analyze_scan",
|
||
|
|
"source": "analyze-code-1",
|
||
|
|
"target": "security-scan-1",
|
||
|
|
"relation_type": "data-flow",
|
||
|
|
"relation_label": "AnalyzeCode outputs metrics → SecurityScan requires code structure",
|
||
|
|
"relation_wording": {
|
||
|
|
"verb": "provides-input-for",
|
||
|
|
"source_output": "metrics (object)",
|
||
|
|
"target_input": "path (string)",
|
||
|
|
"confidence": 0.85
|
||
|
|
}
|
||
|
|
}
|
||
|
|
],
|
||
|
|
"paths": [
|
||
|
|
{
|
||
|
|
"source_id": "analyze-code-1",
|
||
|
|
"target_id": "security-scan-1",
|
||
|
|
"distance": 1,
|
||
|
|
"node_ids": ["analyze-code-1", "security-scan-1"]
|
||
|
|
}
|
||
|
|
],
|
||
|
|
"total_count": 1,
|
||
|
|
"has_more": false,
|
||
|
|
"ranking_profile": "default"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 9. Error Handling
|
||
|
|
|
||
|
|
```go
|
||
|
|
// Temporal retry policy
|
||
|
|
RetryPolicy: &temporal.RetryPolicy{
|
||
|
|
InitialInterval: 2 * time.Second,
|
||
|
|
BackoffCoefficient: 2.0,
|
||
|
|
MaxInterval: 10 * time.Second,
|
||
|
|
MaxAttempts: 3,
|
||
|
|
}
|
||
|
|
|
||
|
|
// Memory System errors propagate
|
||
|
|
if resp.StatusCode != 200 {
|
||
|
|
return fmt.Errorf("Memory System %d: %s", resp.StatusCode, body)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Activity timeouts
|
||
|
|
StartToCloseTimeout: 120 * time.Second
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 10. Testing
|
||
|
|
|
||
|
|
**File:** `tests/workflow_graph_query_test.go`
|
||
|
|
|
||
|
|
```go
|
||
|
|
func TestWorkflowGraphQuery(t *testing.T) {
|
||
|
|
// Setup Temporal test
|
||
|
|
testSuite := &testsuite.WorkflowTestSuite{}
|
||
|
|
env := testSuite.NewTestWorkflowEnvironment()
|
||
|
|
|
||
|
|
// Mock activities
|
||
|
|
env.OnActivity(action.FetchCanvasRelationsActivity, mock.MatchedBy(func(input interface{}) bool {
|
||
|
|
return true
|
||
|
|
})).Return(CanvasWithRelationsData{
|
||
|
|
Nodes: []db.WorkflowNode{...},
|
||
|
|
Edges: []EdgeWithWording{...},
|
||
|
|
}, nil)
|
||
|
|
|
||
|
|
env.OnActivity(action.QueryGraphRAGActivity, mock.MatchedBy(func(input interface{}) bool {
|
||
|
|
return true
|
||
|
|
})).Return(GraphRAGQueryOutput{
|
||
|
|
Edges: []EdgeWithWording{...},
|
||
|
|
TotalCount: 1,
|
||
|
|
}, nil)
|
||
|
|
|
||
|
|
// Execute
|
||
|
|
env.ExecuteWorkflow(statemachine.WorkflowGraphQuery, input)
|
||
|
|
|
||
|
|
// Assert
|
||
|
|
require.True(t, env.IsWorkflowCompleted())
|
||
|
|
require.NoError(t, env.GetWorkflowError())
|
||
|
|
|
||
|
|
var result statemachine.WorkflowGraphQueryOutput
|
||
|
|
err := env.GetWorkflowResult(&result)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Equal(t, 1, result.TotalCount)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|