nomadcode/services/core/internal/authoring/request.go
toki fc84d212d8 update roadmap, add MCP control surface sketches, fix core services config and adapters
- Update agent-roadmap ROADMAP.md and PHASE.md
- Add mcp-control-surface-sketch.md and plane-origin-authoring-roundtrip-sync.md
- Update live-cycle-smoke.md test doc
- Fix services/core: Dockerfile, docker-compose, config, OpenAI client, authoring request
- Update config and test files for core service
2026-06-20 16:29:55 +09:00

173 lines
5.5 KiB
Go

package authoring
import (
"encoding/json"
"strings"
"github.com/nomadcode/nomadcode-core/internal/model"
"github.com/nomadcode/nomadcode-core/internal/storage"
"github.com/nomadcode/nomadcode-core/internal/workitempipeline"
)
// pipelinePayload is the actual task.Payload shape produced by
// workitem.BuildCreateTaskInput. "message" carries the Plane ticket body
// (comment > description > title), and "work_item" carries the provider
// identity map (provider, tenant, project, id, state_id, external_url).
// "prompt" and "instructions" are accepted as a generic override shape.
type pipelinePayload struct {
// pipeline shape (set by workitem.BuildCreateTaskInput)
Message string `json:"message"`
WorkItem map[string]string `json:"work_item"`
// generic override shape
Prompt string `json:"prompt"`
Instructions string `json:"instructions"`
}
const defaultAuthoringInstructions = "다음 메시지를 바탕으로 마일스톤을 작성해."
// isAuthoringTask returns true when the task is a Plane-origin authoring task
// that should be routed through the IOP workspace authoring bridge. The criteria
// are: source=="plane" and task metadata contains a "checkout" key with a
// non-empty slot path.
func isAuthoringTask(task storage.Task) (workitempipeline.CheckoutMetadata, bool) {
if task.Source != "plane" {
return workitempipeline.CheckoutMetadata{}, false
}
if len(task.Metadata) == 0 || string(task.Metadata) == "null" {
return workitempipeline.CheckoutMetadata{}, false
}
var meta struct {
Checkout *workitempipeline.CheckoutMetadata `json:"checkout"`
}
if err := json.Unmarshal(task.Metadata, &meta); err != nil || meta.Checkout == nil {
return workitempipeline.CheckoutMetadata{}, false
}
if strings.TrimSpace(meta.Checkout.SlotPath) == "" {
return workitempipeline.CheckoutMetadata{}, false
}
return *meta.Checkout, true
}
// TaskCheckoutMetadata returns the checkout metadata that makes a task a
// Plane-origin authoring task. It lets terminal finalizers reuse the same
// authoring-task boundary as the model input builder.
func TaskCheckoutMetadata(task storage.Task) (workitempipeline.CheckoutMetadata, bool) {
return isAuthoringTask(task)
}
type workItemIdentity struct {
Provider string
Tenant string
Project string
WorkItemID string
ExternalURL string
StateID string
}
func getWorkItemIdentity(task storage.Task, payload pipelinePayload) workItemIdentity {
var ident workItemIdentity
ident.Provider = task.Source
ident.WorkItemID = task.ID
if task.ExternalProvider != nil && *task.ExternalProvider != "" {
ident.Provider = *task.ExternalProvider
}
if task.ExternalID != nil && *task.ExternalID != "" {
ident.WorkItemID = *task.ExternalID
}
if task.ExternalUrl != nil && *task.ExternalUrl != "" {
ident.ExternalURL = *task.ExternalUrl
}
if p := strings.TrimSpace(payload.WorkItem["provider"]); p != "" {
ident.Provider = p
}
if id := strings.TrimSpace(payload.WorkItem["id"]); id != "" {
ident.WorkItemID = id
}
if t := strings.TrimSpace(payload.WorkItem["tenant"]); t != "" {
ident.Tenant = t
}
if prj := strings.TrimSpace(payload.WorkItem["project"]); prj != "" {
ident.Project = prj
}
if urlStr := strings.TrimSpace(payload.WorkItem["external_url"]); urlStr != "" {
ident.ExternalURL = urlStr
}
if state := strings.TrimSpace(payload.WorkItem["state_id"]); state != "" {
ident.StateID = state
}
if len(task.ExternalMetadata) > 0 && string(task.ExternalMetadata) != "null" && string(task.ExternalMetadata) != "{}" {
var extMeta map[string]any
if err := json.Unmarshal(task.ExternalMetadata, &extMeta); err == nil {
if t, ok := extMeta["tenant"].(string); ok && t != "" && ident.Tenant == "" {
ident.Tenant = t
}
if p, ok := extMeta["project"].(string); ok && p != "" && ident.Project == "" {
ident.Project = p
}
if s, ok := extMeta["state_id"].(string); ok && s != "" && ident.StateID == "" {
ident.StateID = s
}
if u, ok := extMeta["external_url"].(string); ok && u != "" && ident.ExternalURL == "" {
ident.ExternalURL = u
}
}
}
return ident
}
// BuildAuthoringGenerateInput constructs a workspace-bound model.GenerateInput
// for Plane-origin authoring tasks. Returns false when the task does not qualify
// as an authoring task (non-Plane source or missing checkout metadata).
func BuildAuthoringGenerateInput(task storage.Task) (model.GenerateInput, bool) {
checkout, ok := isAuthoringTask(task)
if !ok {
return model.GenerateInput{}, false
}
// input priority: prompt override > pipeline message > task title
input := strings.TrimSpace(task.Title)
instructions := ""
var p pipelinePayload
if len(task.Payload) > 0 && string(task.Payload) != "null" && string(task.Payload) != "{}" {
_ = json.Unmarshal(task.Payload, &p)
}
if body := strings.TrimSpace(p.Message); body != "" {
input = body
}
if override := strings.TrimSpace(p.Prompt); override != "" {
input = override
}
instructions = strings.TrimSpace(p.Instructions)
ident := getWorkItemIdentity(task, p)
if instructions == "" {
instructions = defaultAuthoringInstructions
}
return model.GenerateInput{
Input: input,
Instructions: instructions,
Metadata: map[string]string{
"task_id": task.ID,
"source": task.Source,
},
WorkspaceMetadata: &model.WorkspaceMetadata{
Path: checkout.SlotPath,
SourceBranch: checkout.SourceBranch,
Provider: ident.Provider,
WorkItemID: ident.WorkItemID,
Tenant: ident.Tenant,
Project: ident.Project,
ExternalURL: ident.ExternalURL,
StateID: ident.StateID,
},
}, true
}