1023 lines
34 KiB
Go
1023 lines
34 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.118im.com/im_team/im_workbench/internal/ai"
|
|
"git.118im.com/im_team/im_workbench/internal/auth"
|
|
"git.118im.com/im_team/im_workbench/internal/store"
|
|
"git.118im.com/im_team/im_workbench/internal/tableparse"
|
|
)
|
|
|
|
const (
|
|
reasonAINotConfigured = "AI_PROVIDER_NOT_CONFIGURED"
|
|
reasonAIProviderFailed = "AI_PROVIDER_FAILED"
|
|
reasonAITimeout = "AI_TIMEOUT"
|
|
reasonAITaskFailed = "AI_TASK_FAILED"
|
|
reasonAIFeatureClosed = "AI_FEATURE_DISABLED"
|
|
)
|
|
|
|
func (a *API) aiCapabilities(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w, r)
|
|
return
|
|
}
|
|
caps, err := st.AICapabilities(r.Context())
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, map[string]any{
|
|
"enabled": anyCapabilityEnabled(caps),
|
|
"features": aiCapabilityPayloads(caps),
|
|
})
|
|
}
|
|
|
|
func (a *API) aiTextPolish(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w, r)
|
|
return
|
|
}
|
|
if _, ok := requireIdempotencyKey(w, r); !ok {
|
|
return
|
|
}
|
|
var body aiPolishRequest
|
|
if !decodeBody(w, r, &body) {
|
|
return
|
|
}
|
|
feature := aiPolishFeature(body.Scene)
|
|
cap, ok := a.requireAICapability(w, r, st, feature)
|
|
if !ok {
|
|
return
|
|
}
|
|
source := strings.TrimSpace(body.SourceText)
|
|
maxInput := aiMaxInputChars(cap, 1000)
|
|
if source == "" || len([]rune(source)) > maxInput {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "source_text", "max_length": maxInput})
|
|
return
|
|
}
|
|
maxOutput := body.MaxOutputChars
|
|
if maxOutput <= 0 {
|
|
maxOutput = 300
|
|
}
|
|
result, err := a.ai.Polish(r.Context(), source, strings.TrimSpace(body.Scene), strings.TrimSpace(body.Tone), maxOutput)
|
|
if err != nil {
|
|
a.writeAIError(w, r, err)
|
|
_ = st.InsertAIUsageLog(r.Context(), store.AIUsageLog{
|
|
TenantCode: p.TenantCode, UserID: formatUserID(p.UserID), Feature: feature,
|
|
Provider: "deepseek", Status: "failed", ErrorReason: aiErrorReason(err), InputHash: hashText(source),
|
|
})
|
|
return
|
|
}
|
|
_ = st.InsertAIUsageLog(r.Context(), store.AIUsageLog{
|
|
TenantCode: p.TenantCode, UserID: formatUserID(p.UserID), Feature: feature,
|
|
Provider: "deepseek", Model: result.Usage.Model, ProviderRequestID: result.Usage.ProviderRequestID,
|
|
PromptTokens: result.Usage.PromptTokens, CompletionTokens: result.Usage.CompletionTokens, TotalTokens: result.Usage.TotalTokens,
|
|
LatencyMS: int(result.Usage.LatencyMS), Status: "succeeded", InputHash: hashText(source), OutputHash: hashText(result.Text),
|
|
})
|
|
writeSuccess(w, r, map[string]any{
|
|
"source_text": source,
|
|
"polished_text": result.Text,
|
|
"usage": map[string]any{"remaining_today": 0},
|
|
})
|
|
}
|
|
|
|
func (a *API) aiDocumentGenerate(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w, r)
|
|
return
|
|
}
|
|
if _, ok := requireIdempotencyKey(w, r); !ok {
|
|
return
|
|
}
|
|
var body aiDocumentGenerateRequest
|
|
if !decodeBody(w, r, &body) {
|
|
return
|
|
}
|
|
cap, ok := a.requireAICapability(w, r, st, "smart_document")
|
|
if !ok {
|
|
return
|
|
}
|
|
source := strings.TrimSpace(body.SourceText)
|
|
maxInput := aiMaxInputChars(cap, 1000)
|
|
if source == "" || len([]rune(source)) > maxInput {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "source_text", "max_length": maxInput})
|
|
return
|
|
}
|
|
taskID := "aitask_" + randomToken(12)
|
|
started := time.Now().UTC().Round(time.Millisecond)
|
|
if err := st.CreateAITask(r.Context(), store.AITask{
|
|
TaskID: taskID, TenantCode: p.TenantCode, UserID: formatUserID(p.UserID), Feature: "smart_document",
|
|
Status: "processing", Progress: 10, SourceType: "text", InputHash: hashText(source),
|
|
PromptVersion: "smart_document_v1", Model: "",
|
|
StartedAt: &started,
|
|
}, nil); err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
result, err := a.ai.GenerateDocument(r.Context(), source, strings.TrimSpace(body.TitleHint), strings.TrimSpace(body.DocumentType), strings.TrimSpace(body.Language))
|
|
if err != nil {
|
|
_ = st.UpdateAITaskResult(r.Context(), taskID, "failed", 100, "", nil, aiErrorReason(err), err.Error())
|
|
_ = st.InsertAIUsageLog(r.Context(), store.AIUsageLog{
|
|
TenantCode: p.TenantCode, UserID: formatUserID(p.UserID), Feature: "smart_document", TaskID: taskID,
|
|
Provider: "deepseek", Status: "failed", ErrorReason: aiErrorReason(err), InputHash: hashText(source),
|
|
})
|
|
a.writeAIError(w, r, err)
|
|
return
|
|
}
|
|
taskResult := map[string]any{
|
|
"title": result.Title,
|
|
"summary": result.Summary,
|
|
"content_markdown": result.ContentMarkdown,
|
|
"content_plain": result.ContentPlain,
|
|
"sections": result.Sections,
|
|
"source_excerpt": excerpt(source, 200),
|
|
"can_save": true,
|
|
}
|
|
if err := st.UpdateAITaskResult(r.Context(), taskID, "succeeded", 100, "", taskResult, "", ""); err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
_ = st.InsertAIUsageLog(r.Context(), store.AIUsageLog{
|
|
TenantCode: p.TenantCode, UserID: formatUserID(p.UserID), Feature: "smart_document", TaskID: taskID,
|
|
Provider: "deepseek", Model: result.Usage.Model, ProviderRequestID: result.Usage.ProviderRequestID,
|
|
PromptTokens: result.Usage.PromptTokens, CompletionTokens: result.Usage.CompletionTokens, TotalTokens: result.Usage.TotalTokens,
|
|
LatencyMS: int(result.Usage.LatencyMS), Status: "succeeded", InputHash: hashText(source), OutputHash: hashText(result.ContentMarkdown),
|
|
})
|
|
writeSuccess(w, r, map[string]any{
|
|
"task_id": taskID,
|
|
"status": "succeeded",
|
|
"polling_interval_ms": 1500,
|
|
"expires_at": time.Now().UTC().Add(2 * time.Hour).Format(time.RFC3339Nano),
|
|
})
|
|
}
|
|
|
|
func (a *API) aiTaskResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
taskID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/ai/tasks/")
|
|
if !ok {
|
|
writeError(w, r, http.StatusNotFound, 30004, reasonNotFound, "resource not found", nil)
|
|
return
|
|
}
|
|
if tail == "cancel" && r.Method == http.MethodPost {
|
|
if _, ok := requireIdempotencyKey(w, r); !ok {
|
|
return
|
|
}
|
|
if err := st.CancelAITask(r.Context(), p.UserID, taskID); err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, map[string]any{"task_id": taskID, "status": "canceled", "canceled_at": time.Now().UTC().Format(time.RFC3339Nano)})
|
|
return
|
|
}
|
|
if tail != "" {
|
|
writeError(w, r, http.StatusNotFound, 30004, reasonNotFound, "resource not found", nil)
|
|
return
|
|
}
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w, r)
|
|
return
|
|
}
|
|
task, err := st.AITask(r.Context(), p.UserID, taskID)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, aiTaskPayload(task))
|
|
}
|
|
|
|
func (a *API) aiDocuments(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
items, next, err := st.AIDocuments(r.Context(), p.UserID, strings.TrimSpace(r.URL.Query().Get("query")), r.URL.Query().Get("cursor"), limit(r, 20, 50))
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, pagePayload(aiRecordPayloads(items), next))
|
|
case http.MethodPost:
|
|
a.aiDocumentSave(w, r, p, st)
|
|
default:
|
|
methodNotAllowed(w, r)
|
|
}
|
|
}
|
|
|
|
func (a *API) aiDocumentSave(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
if _, ok := requireIdempotencyKey(w, r); !ok {
|
|
return
|
|
}
|
|
var body aiDocumentSaveRequest
|
|
if !decodeBody(w, r, &body) {
|
|
return
|
|
}
|
|
title := strings.TrimSpace(body.Title)
|
|
content := strings.TrimSpace(body.ContentMarkdown)
|
|
if title == "" || content == "" || strings.TrimSpace(body.TaskID) == "" {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", nil)
|
|
return
|
|
}
|
|
task, err := st.AITask(r.Context(), p.UserID, strings.TrimSpace(body.TaskID))
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
if task.Status != "succeeded" {
|
|
writeError(w, r, http.StatusConflict, 30040, reasonConflict, "conflict", map[string]any{"status": task.Status})
|
|
return
|
|
}
|
|
recordID := "airec_doc_" + randomToken(10)
|
|
sectionsJSON := "[]"
|
|
if len(body.Sections) > 0 {
|
|
if data, err := json.Marshal(body.Sections); err == nil {
|
|
sectionsJSON = string(data)
|
|
}
|
|
} else if task.ResultJSON.Valid {
|
|
var result map[string]any
|
|
if json.Unmarshal([]byte(task.ResultJSON.String), &result) == nil {
|
|
if sections, ok := result["sections"]; ok {
|
|
if data, err := json.Marshal(sections); err == nil {
|
|
sectionsJSON = string(data)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if err := st.SaveAIDocument(r.Context(), p.TenantCode, p.UserID, store.AIRecord{
|
|
RecordID: recordID, RecordType: "smart_document", Title: title, Summary: excerpt(firstNonEmpty(body.ContentPlain, content), 120),
|
|
Status: "succeeded", TaskID: task.TaskID,
|
|
}, store.AIDocument{
|
|
RecordID: recordID, DocumentType: firstNonEmpty(body.DocumentType, "general"), Title: title, ContentMarkdown: content,
|
|
ContentPlain: strings.TrimSpace(body.ContentPlain), SectionsJSON: sectionsJSON, SourceExcerpt: "", PromptVersion: task.PromptVersion, Model: task.Model,
|
|
}); err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, map[string]any{
|
|
"record_id": recordID,
|
|
"title": title,
|
|
"created_at": time.Now().UTC().Format(time.RFC3339Nano),
|
|
"updated_at": time.Now().UTC().Format(time.RFC3339Nano),
|
|
})
|
|
}
|
|
|
|
func (a *API) aiDocumentResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
recordID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/ai/documents/")
|
|
if !ok || tail != "" {
|
|
writeError(w, r, http.StatusNotFound, 30004, reasonNotFound, "resource not found", nil)
|
|
return
|
|
}
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w, r)
|
|
return
|
|
}
|
|
doc, err := st.AIDocument(r.Context(), p.UserID, recordID)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, aiDocumentPayload(doc))
|
|
}
|
|
|
|
func (a *API) aiRecords(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w, r)
|
|
return
|
|
}
|
|
items, next, err := st.AIRecords(r.Context(), p.UserID, strings.TrimSpace(r.URL.Query().Get("type")), strings.TrimSpace(r.URL.Query().Get("query")), r.URL.Query().Get("cursor"), limit(r, 20, 50))
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, pagePayload(aiGenericRecordPayloads(items), next))
|
|
}
|
|
|
|
func (a *API) aiRecordResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
recordID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/ai/records/")
|
|
if !ok || tail != "" {
|
|
writeError(w, r, http.StatusNotFound, 30004, reasonNotFound, "resource not found", nil)
|
|
return
|
|
}
|
|
if r.Method != http.MethodDelete {
|
|
methodNotAllowed(w, r)
|
|
return
|
|
}
|
|
if err := st.DeleteAIRecord(r.Context(), p.UserID, recordID); err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, map[string]any{"record_id": recordID, "deleted": true})
|
|
}
|
|
|
|
func (a *API) aiTableAnalyses(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
items, next, err := st.AITableAnalyses(r.Context(), p.UserID, strings.TrimSpace(r.URL.Query().Get("query")), r.URL.Query().Get("cursor"), limit(r, 20, 50))
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, pagePayload(aiTableRecordPayloads(items), next))
|
|
case http.MethodPost:
|
|
a.aiTableAnalysisCreate(w, r, p, st)
|
|
default:
|
|
methodNotAllowed(w, r)
|
|
}
|
|
}
|
|
|
|
func (a *API) aiTableAnalysisCreate(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
if _, ok := requireIdempotencyKey(w, r); !ok {
|
|
return
|
|
}
|
|
var body aiTableAnalysisRequest
|
|
if !decodeBody(w, r, &body) {
|
|
return
|
|
}
|
|
cap, ok := a.requireAICapability(w, r, st, "smart_table")
|
|
if !ok {
|
|
return
|
|
}
|
|
fileID := strings.TrimSpace(body.FileID)
|
|
topic := strings.TrimSpace(body.Topic)
|
|
fileType := strings.ToLower(strings.TrimSpace(body.FileType))
|
|
if fileID == "" || topic == "" || !allowedTableFileType(fileType) {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", nil)
|
|
return
|
|
}
|
|
maxInput := aiMaxInputChars(cap, 100)
|
|
if len([]rune(topic)) > maxInput {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "topic", "max_length": maxInput})
|
|
return
|
|
}
|
|
fileMeta := st.AIFileMetadata(r.Context(), fileID)
|
|
fileSize := body.FileSize
|
|
if fileSize <= 0 && fileMeta.FileSize > 0 {
|
|
fileSize = fileMeta.FileSize
|
|
}
|
|
if cap.MaxFileSizeMB > 0 && fileSize > int64(cap.MaxFileSizeMB)*1024*1024 {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "file_size", "max_file_size_mb": cap.MaxFileSizeMB})
|
|
return
|
|
}
|
|
taskID := "aitask_tbl_" + randomToken(12)
|
|
recordID := "airec_tbl_" + randomToken(10)
|
|
started := time.Now().UTC().Round(time.Millisecond)
|
|
if err := st.CreateAITask(r.Context(), store.AITask{
|
|
TaskID: taskID, TenantCode: p.TenantCode, UserID: formatUserID(p.UserID), Feature: "smart_table",
|
|
Status: "processing", Progress: 20, SourceType: "file", SourceRefID: fileID, InputHash: hashText(fileID + ":" + topic),
|
|
PromptVersion: "smart_table_v1", StartedAt: &started,
|
|
}, nil); err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
profile := tableProfileForAI(resolveTableFilePath(fileID, fileMeta.Location), fileType)
|
|
result, err := a.ai.AnalyzeTable(r.Context(), ai.TableAnalysisInput{
|
|
Topic: topic, FileID: fileID, FileName: strings.TrimSpace(body.FileName), FileType: fileType, FileSize: fileSize, Profile: profile,
|
|
})
|
|
if err != nil {
|
|
_ = st.UpdateAITaskResult(r.Context(), taskID, "failed", 100, "", nil, aiErrorReason(err), err.Error())
|
|
a.writeAIError(w, r, err)
|
|
return
|
|
}
|
|
metricsJSON := mustJSON(result.Metrics, "[]")
|
|
previewJSON := mustJSON(result.Preview, "{}")
|
|
alertsJSON := mustJSON(result.Alerts, "[]")
|
|
if err := st.SaveAITableAnalysis(r.Context(), p.TenantCode, p.UserID, store.AIRecord{
|
|
RecordID: recordID, RecordType: "smart_table", Title: result.Title, Summary: excerpt(firstNonEmpty(result.Summary, result.Interpretation), 160),
|
|
Status: "succeeded", TaskID: taskID,
|
|
}, store.AITableAnalysis{
|
|
RecordID: recordID, FileID: fileID, FileName: strings.TrimSpace(body.FileName), FileType: fileType, FileSize: fileSize,
|
|
Topic: topic, MetricsJSON: metricsJSON, PreviewJSON: previewJSON, AlertsJSON: alertsJSON, Interpretation: result.Interpretation,
|
|
PromptVersion: "smart_table_v1", Model: result.Usage.Model,
|
|
}); err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
taskResult := map[string]any{"record_id": recordID, "title": result.Title, "metrics": result.Metrics, "alerts": result.Alerts, "interpretation": result.Interpretation}
|
|
if err := st.UpdateAITaskResult(r.Context(), taskID, "succeeded", 100, recordID, taskResult, "", ""); err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
_ = st.InsertAIUsageLog(r.Context(), store.AIUsageLog{
|
|
TenantCode: p.TenantCode, UserID: formatUserID(p.UserID), Feature: "smart_table", TaskID: taskID,
|
|
Provider: "deepseek", Model: result.Usage.Model, ProviderRequestID: result.Usage.ProviderRequestID,
|
|
PromptTokens: result.Usage.PromptTokens, CompletionTokens: result.Usage.CompletionTokens, TotalTokens: result.Usage.TotalTokens,
|
|
LatencyMS: int(result.Usage.LatencyMS), Status: "succeeded", InputHash: hashText(fileID + ":" + topic), OutputHash: hashText(result.Interpretation),
|
|
})
|
|
writeSuccess(w, r, map[string]any{"task_id": taskID, "record_id": recordID, "status": "succeeded", "polling_interval_ms": 1500})
|
|
}
|
|
|
|
func (a *API) aiTableAnalysisResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
recordID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/ai/table-analyses/")
|
|
if !ok {
|
|
writeError(w, r, http.StatusNotFound, 30004, reasonNotFound, "resource not found", nil)
|
|
return
|
|
}
|
|
if tail == "export" && r.Method == http.MethodPost {
|
|
if _, ok := requireIdempotencyKey(w, r); !ok {
|
|
return
|
|
}
|
|
if _, err := st.AITableAnalysis(r.Context(), p.UserID, recordID); err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, map[string]any{
|
|
"file_id": "fil_export_" + randomToken(8),
|
|
"file_name": recordID + "-解读报告.docx",
|
|
"download_url": "",
|
|
"expires_at": time.Now().UTC().Add(24 * time.Hour).Format(time.RFC3339Nano),
|
|
})
|
|
return
|
|
}
|
|
if tail != "" || r.Method != http.MethodGet {
|
|
methodNotAllowed(w, r)
|
|
return
|
|
}
|
|
item, err := st.AITableAnalysis(r.Context(), p.UserID, recordID)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, aiTableAnalysisPayload(item))
|
|
}
|
|
|
|
func (a *API) aiMeetingSummary(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store, meetingID string) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
if _, err := st.Meeting(r.Context(), p.UserID, meetingID); err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
summary, err := st.AIMeetingSummary(r.Context(), p.UserID, meetingID)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
writeSuccess(w, r, map[string]any{"meeting_id": meetingID, "status": "empty"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, aiMeetingSummaryPayload(summary))
|
|
case http.MethodPost:
|
|
a.aiMeetingSummaryCreate(w, r, p, st, meetingID)
|
|
case http.MethodPut:
|
|
a.aiMeetingSummaryUpdate(w, r, p, st, meetingID)
|
|
default:
|
|
methodNotAllowed(w, r)
|
|
}
|
|
}
|
|
|
|
func (a *API) aiMeetingSummaryCreate(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store, meetingID string) {
|
|
if _, ok := requireIdempotencyKey(w, r); !ok {
|
|
return
|
|
}
|
|
var body aiMeetingSummaryRequest
|
|
if !decodeBody(w, r, &body) {
|
|
return
|
|
}
|
|
if _, ok := a.requireAICapability(w, r, st, "meeting_summary"); !ok {
|
|
return
|
|
}
|
|
meeting, err := st.Meeting(r.Context(), p.UserID, meetingID)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
files, err := st.MeetingNoteFiles(r.Context(), p.UserID, meetingID)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
fileNames, fileIDs := meetingSummaryFiles(files, body.FileIDs)
|
|
taskID := "aitask_mtg_" + randomToken(12)
|
|
summaryID := "aisum_" + randomToken(10)
|
|
started := time.Now().UTC().Round(time.Millisecond)
|
|
if err := st.CreateAITask(r.Context(), store.AITask{
|
|
TaskID: taskID, TenantCode: p.TenantCode, UserID: formatUserID(p.UserID), Feature: "meeting_summary",
|
|
Status: "processing", Progress: 20, SourceType: "meeting", SourceRefID: meetingID, InputHash: hashText(meetingID + strings.Join(fileIDs, ",")),
|
|
PromptVersion: "meeting_summary_v1", StartedAt: &started,
|
|
}, nil); err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
result, err := a.ai.SummarizeMeeting(r.Context(), ai.MeetingSummaryInput{
|
|
MeetingTitle: meeting.Title, StartAt: meeting.StartAt.UTC().Format(time.RFC3339Nano), EndAt: meeting.EndAt.UTC().Format(time.RFC3339Nano),
|
|
Participants: meetingSummaryParticipants(*meeting), Files: fileNames,
|
|
})
|
|
if err != nil {
|
|
_ = st.UpdateAITaskResult(r.Context(), taskID, "failed", 100, "", nil, aiErrorReason(err), err.Error())
|
|
a.writeAIError(w, r, err)
|
|
return
|
|
}
|
|
if err := st.UpsertAIMeetingSummary(r.Context(), p.TenantCode, p.UserID, store.AIMeetingSummary{
|
|
SummaryID: summaryID, MeetingID: meetingID, TaskID: taskID,
|
|
ConclusionsJSON: mustJSON(result.Conclusions, "[]"), DecisionsJSON: mustJSON(result.Decisions, "[]"),
|
|
ActionItemsJSON: mustJSON(result.ActionItems, "[]"), SourceFileIDsJSON: mustJSON(fileIDs, "[]"),
|
|
PromptVersion: "meeting_summary_v1", Model: result.Usage.Model,
|
|
}); err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
_ = st.UpdateAITaskResult(r.Context(), taskID, "succeeded", 100, summaryID, map[string]any{
|
|
"summary_id": summaryID, "meeting_id": meetingID, "conclusions": result.Conclusions, "decisions": result.Decisions, "action_items": result.ActionItems,
|
|
}, "", "")
|
|
writeSuccess(w, r, map[string]any{"task_id": taskID, "meeting_id": meetingID, "status": "succeeded", "polling_interval_ms": 1500})
|
|
}
|
|
|
|
func (a *API) aiMeetingSummaryUpdate(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store, meetingID string) {
|
|
if _, ok := requireIdempotencyKey(w, r); !ok {
|
|
return
|
|
}
|
|
var body aiMeetingSummaryUpdateRequest
|
|
if !decodeBody(w, r, &body) {
|
|
return
|
|
}
|
|
summaryID := strings.TrimSpace(body.SummaryID)
|
|
if summaryID == "" {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "summary_id"})
|
|
return
|
|
}
|
|
summary, err := st.UpdateAIMeetingSummary(r.Context(), p.UserID, meetingID, summaryID, mustJSON(body.Conclusions, "[]"), mustJSON(body.Decisions, "[]"), mustJSON(body.ActionItems, "[]"))
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, aiMeetingSummaryPayload(summary))
|
|
}
|
|
|
|
func (a *API) workbenchApps(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w, r)
|
|
return
|
|
}
|
|
modules, _, err := st.WorkbenchModules(r.Context(), p.UserID)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
apps := workbenchAppPayloads(modules)
|
|
caps, err := st.AICapabilities(r.Context())
|
|
if err == nil {
|
|
apps = append(apps, aiAppPayloads(caps)...)
|
|
}
|
|
writeSuccess(w, r, map[string]any{"common_apps": apps, "all_apps": apps})
|
|
}
|
|
|
|
func (a *API) workbenchAppsSearch(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w, r)
|
|
return
|
|
}
|
|
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
|
if query == "" {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "q"})
|
|
return
|
|
}
|
|
modules, _, err := st.WorkbenchModules(r.Context(), p.UserID)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
apps := workbenchAppPayloads(modules)
|
|
if caps, err := st.AICapabilities(r.Context()); err == nil {
|
|
apps = append(apps, aiAppPayloads(caps)...)
|
|
}
|
|
items := make([]map[string]any, 0)
|
|
for _, app := range apps {
|
|
name, _ := app["name"].(string)
|
|
if strings.Contains(strings.ToLower(name), strings.ToLower(query)) {
|
|
app["matched_text"] = name
|
|
items = append(items, app)
|
|
}
|
|
}
|
|
writeSuccess(w, r, map[string]any{"items": items, "has_more": false})
|
|
}
|
|
|
|
func (a *API) writeAIError(w http.ResponseWriter, r *http.Request, err error) {
|
|
switch {
|
|
case errors.Is(err, ai.ErrNotConfigured):
|
|
writeError(w, r, http.StatusServiceUnavailable, 30050, reasonAINotConfigured, "ai provider not configured", nil)
|
|
case errors.Is(err, ai.ErrTimeout):
|
|
writeError(w, r, http.StatusGatewayTimeout, 30051, reasonAITimeout, "ai timeout", nil)
|
|
case errors.Is(err, ai.ErrBadOutput):
|
|
writeError(w, r, http.StatusBadGateway, 30052, reasonAITaskFailed, "ai task failed", nil)
|
|
default:
|
|
writeError(w, r, http.StatusBadGateway, 30053, reasonAIProviderFailed, "ai provider failed", nil)
|
|
}
|
|
}
|
|
|
|
func aiErrorReason(err error) string {
|
|
switch {
|
|
case errors.Is(err, ai.ErrNotConfigured):
|
|
return reasonAINotConfigured
|
|
case errors.Is(err, ai.ErrTimeout):
|
|
return reasonAITimeout
|
|
case errors.Is(err, ai.ErrBadOutput):
|
|
return reasonAITaskFailed
|
|
default:
|
|
return reasonAIProviderFailed
|
|
}
|
|
}
|
|
|
|
func (a *API) requireAICapability(w http.ResponseWriter, r *http.Request, st *store.Store, feature string) (store.AICapability, bool) {
|
|
caps, err := st.AICapabilities(r.Context())
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return store.AICapability{}, false
|
|
}
|
|
for _, cap := range caps {
|
|
if cap.Feature != feature {
|
|
continue
|
|
}
|
|
if !cap.Enabled {
|
|
writeError(w, r, http.StatusForbidden, 30055, reasonAIFeatureClosed, "ai feature disabled", map[string]any{"feature": feature})
|
|
return store.AICapability{}, false
|
|
}
|
|
return cap, true
|
|
}
|
|
writeError(w, r, http.StatusForbidden, 30055, reasonAIFeatureClosed, "ai feature disabled", map[string]any{"feature": feature})
|
|
return store.AICapability{}, false
|
|
}
|
|
|
|
func aiPolishFeature(scene string) string {
|
|
switch strings.TrimSpace(scene) {
|
|
case "daily_report":
|
|
return "daily_report_polish"
|
|
case "monthly_report":
|
|
return "monthly_report_polish"
|
|
default:
|
|
return "text_polish"
|
|
}
|
|
}
|
|
|
|
func aiMaxInputChars(cap store.AICapability, fallback int) int {
|
|
if cap.MaxInputChars > 0 {
|
|
return cap.MaxInputChars
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
type aiPolishRequest struct {
|
|
SourceText string `json:"source_text"`
|
|
Scene string `json:"scene"`
|
|
Tone string `json:"tone"`
|
|
MaxOutputChars int `json:"max_output_chars"`
|
|
}
|
|
|
|
type aiDocumentGenerateRequest struct {
|
|
SourceText string `json:"source_text"`
|
|
TitleHint string `json:"title_hint"`
|
|
DocumentType string `json:"document_type"`
|
|
Language string `json:"language"`
|
|
}
|
|
|
|
type aiDocumentSaveRequest struct {
|
|
TaskID string `json:"task_id"`
|
|
Title string `json:"title"`
|
|
DocumentType string `json:"document_type"`
|
|
ContentMarkdown string `json:"content_markdown"`
|
|
ContentPlain string `json:"content_plain"`
|
|
Sections []ai.DocumentSection `json:"sections"`
|
|
}
|
|
|
|
type aiTableAnalysisRequest struct {
|
|
FileID string `json:"file_id"`
|
|
FileName string `json:"file_name"`
|
|
FileType string `json:"file_type"`
|
|
FileSize int64 `json:"file_size"`
|
|
Topic string `json:"topic"`
|
|
SheetName string `json:"sheet_name"`
|
|
Language string `json:"language"`
|
|
}
|
|
|
|
type aiMeetingSummaryRequest struct {
|
|
FileIDs []string `json:"file_ids"`
|
|
SummaryType string `json:"summary_type"`
|
|
ForceRegenerate bool `json:"force_regenerate"`
|
|
}
|
|
|
|
type aiMeetingSummaryUpdateRequest struct {
|
|
SummaryID string `json:"summary_id"`
|
|
Conclusions []string `json:"conclusions"`
|
|
Decisions []string `json:"decisions"`
|
|
ActionItems []ai.MeetingActionItem `json:"action_items"`
|
|
}
|
|
|
|
func aiCapabilityPayloads(caps []store.AICapability) []map[string]any {
|
|
items := make([]map[string]any, 0, len(caps))
|
|
for _, cap := range caps {
|
|
cfg := jsonObject(cap.ConfigJSON)
|
|
title, _ := cfg["entry_title"].(string)
|
|
items = append(items, map[string]any{
|
|
"feature": cap.Feature,
|
|
"enabled": cap.Enabled,
|
|
"entry_title": firstNonEmpty(title, cap.Feature),
|
|
"daily_limit": cap.DailyLimit,
|
|
"remaining_today": cap.DailyLimit,
|
|
"max_input_chars": cap.MaxInputChars,
|
|
"max_file_size_mb": cap.MaxFileSizeMB,
|
|
"model": cap.Model,
|
|
})
|
|
}
|
|
return items
|
|
}
|
|
|
|
func anyCapabilityEnabled(caps []store.AICapability) bool {
|
|
for _, cap := range caps {
|
|
if cap.Enabled {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func aiTaskPayload(task *store.AITask) map[string]any {
|
|
var result any
|
|
if task.ResultJSON.Valid && strings.TrimSpace(task.ResultJSON.String) != "" {
|
|
var decoded any
|
|
if json.Unmarshal([]byte(task.ResultJSON.String), &decoded) == nil {
|
|
result = decoded
|
|
}
|
|
}
|
|
var errPayload any
|
|
if task.ErrorReason != "" {
|
|
errPayload = map[string]any{"reason": task.ErrorReason, "message": task.ErrorMessage}
|
|
}
|
|
return map[string]any{
|
|
"task_id": task.TaskID,
|
|
"feature": task.Feature,
|
|
"status": task.Status,
|
|
"progress": task.Progress,
|
|
"steps": []any{},
|
|
"result": result,
|
|
"error": errPayload,
|
|
}
|
|
}
|
|
|
|
func aiRecordPayloads(records []store.AIRecord) []map[string]any {
|
|
items := make([]map[string]any, 0, len(records))
|
|
for _, record := range records {
|
|
items = append(items, map[string]any{
|
|
"record_id": record.RecordID,
|
|
"title": record.Title,
|
|
"summary": record.Summary,
|
|
"document_type": "general",
|
|
"status": record.Status,
|
|
"created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
})
|
|
}
|
|
return items
|
|
}
|
|
|
|
func aiTableRecordPayloads(records []store.AIRecord) []map[string]any {
|
|
items := make([]map[string]any, 0, len(records))
|
|
for _, record := range records {
|
|
items = append(items, map[string]any{
|
|
"record_id": record.RecordID,
|
|
"title": record.Title,
|
|
"summary": record.Summary,
|
|
"status": record.Status,
|
|
"created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
})
|
|
}
|
|
return items
|
|
}
|
|
|
|
func aiGenericRecordPayloads(records []store.AIRecord) []map[string]any {
|
|
items := make([]map[string]any, 0, len(records))
|
|
for _, record := range records {
|
|
items = append(items, map[string]any{
|
|
"record_id": record.RecordID,
|
|
"type": record.RecordType,
|
|
"title": record.Title,
|
|
"summary": record.Summary,
|
|
"status": record.Status,
|
|
"created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
"route": aiRecordRoute(record),
|
|
})
|
|
}
|
|
return items
|
|
}
|
|
|
|
func aiRecordRoute(record store.AIRecord) string {
|
|
switch record.RecordType {
|
|
case "smart_document":
|
|
return "/workbench/ai/document/" + record.RecordID
|
|
case "smart_table":
|
|
return "/workbench/ai/table/" + record.RecordID
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func aiDocumentPayload(doc *store.AIDocument) map[string]any {
|
|
var sections any = []any{}
|
|
if strings.TrimSpace(doc.SectionsJSON) != "" {
|
|
var decoded any
|
|
if json.Unmarshal([]byte(doc.SectionsJSON), &decoded) == nil {
|
|
sections = decoded
|
|
}
|
|
}
|
|
return map[string]any{
|
|
"record_id": doc.RecordID,
|
|
"title": doc.Title,
|
|
"document_type": doc.DocumentType,
|
|
"content_markdown": doc.ContentMarkdown,
|
|
"content_plain": doc.ContentPlain,
|
|
"sections": sections,
|
|
"source_excerpt": doc.SourceExcerpt,
|
|
"created_at": doc.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"updated_at": doc.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func aiTableAnalysisPayload(item *store.AITableAnalysis) map[string]any {
|
|
return map[string]any{
|
|
"record_id": item.RecordID,
|
|
"title": item.Topic,
|
|
"file": map[string]any{
|
|
"file_id": item.FileID, "file_name": item.FileName, "file_type": item.FileType, "file_size": item.FileSize,
|
|
},
|
|
"metrics": decodeJSONAny(item.MetricsJSON, []any{}),
|
|
"preview": decodeJSONAny(item.PreviewJSON, map[string]any{}),
|
|
"alerts": decodeJSONAny(item.AlertsJSON, []any{}),
|
|
"interpretation": item.Interpretation,
|
|
"export_options": []string{"docx", "pdf"},
|
|
"created_at": item.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"updated_at": item.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func aiMeetingSummaryPayload(item *store.AIMeetingSummary) map[string]any {
|
|
payload := map[string]any{
|
|
"meeting_id": item.MeetingID,
|
|
"summary_id": item.SummaryID,
|
|
"status": "succeeded",
|
|
"conclusions": decodeJSONAny(item.ConclusionsJSON, []any{}),
|
|
"decisions": decodeJSONAny(item.DecisionsJSON, []any{}),
|
|
"action_items": decodeJSONAny(item.ActionItemsJSON, []any{}),
|
|
"editable": true,
|
|
"version": item.Version,
|
|
"updated_at": item.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if item.EditedAt != nil {
|
|
payload["edited_at"] = item.EditedAt.UTC().Format(time.RFC3339Nano)
|
|
payload["edited_by"] = item.EditedBy
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func meetingSummaryFiles(files []store.MeetingNoteFile, requested []string) ([]string, []string) {
|
|
filter := map[string]bool{}
|
|
for _, id := range requested {
|
|
id = strings.TrimSpace(id)
|
|
if id != "" {
|
|
filter[id] = true
|
|
}
|
|
}
|
|
names := make([]string, 0, len(files))
|
|
ids := make([]string, 0, len(files))
|
|
for _, file := range files {
|
|
if len(filter) > 0 && !filter[file.FileID] {
|
|
continue
|
|
}
|
|
names = append(names, file.FileName)
|
|
ids = append(ids, file.FileID)
|
|
}
|
|
return names, ids
|
|
}
|
|
|
|
func meetingSummaryParticipants(meeting store.Meeting) []string {
|
|
items := make([]string, 0, len(meeting.Participants)+1)
|
|
if meeting.Organizer.DisplayName != "" {
|
|
items = append(items, meeting.Organizer.DisplayName)
|
|
}
|
|
for _, participant := range meeting.Participants {
|
|
name := participant.User.DisplayName
|
|
if name == "" {
|
|
name = formatUserID(participant.UserID)
|
|
}
|
|
items = append(items, name)
|
|
}
|
|
return items
|
|
}
|
|
|
|
func resolveTableFilePath(fileID, location string) string {
|
|
candidates := []string{strings.TrimSpace(location), strings.TrimSpace(fileID)}
|
|
for _, candidate := range candidates {
|
|
if candidate == "" || strings.HasPrefix(candidate, "http://") || strings.HasPrefix(candidate, "https://") {
|
|
continue
|
|
}
|
|
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
|
return candidate
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func tableProfileForAI(path, fileType string) ai.TableProfile {
|
|
if strings.TrimSpace(path) == "" {
|
|
return ai.TableProfile{Parsed: false, Message: "source file is not available locally"}
|
|
}
|
|
profile, err := tableparse.Parse(path, fileType)
|
|
if err != nil {
|
|
return ai.TableProfile{Parsed: false, Message: err.Error()}
|
|
}
|
|
return ai.TableProfile{
|
|
Columns: profile.Columns,
|
|
SampleRows: profile.SampleRows,
|
|
RowCount: profile.RowCount,
|
|
Stats: profile.Stats,
|
|
Outliers: profile.Outliers,
|
|
Parsed: profile.Parsed,
|
|
Message: profile.Message,
|
|
}
|
|
}
|
|
|
|
func allowedTableFileType(value string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "xlsx", "xls", "csv":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func mustJSON(value any, fallback string) string {
|
|
data, err := json.Marshal(value)
|
|
if err != nil || len(data) == 0 || string(data) == "null" {
|
|
return fallback
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func decodeJSONAny(raw string, fallback any) any {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return fallback
|
|
}
|
|
var out any
|
|
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
|
return fallback
|
|
}
|
|
return out
|
|
}
|
|
|
|
func workbenchAppPayloads(modules []store.WorkbenchModule) []map[string]any {
|
|
items := make([]map[string]any, 0, len(modules))
|
|
for _, module := range modules {
|
|
items = append(items, map[string]any{
|
|
"app_id": module.Code,
|
|
"name": module.Name,
|
|
"icon": module.IconFileID,
|
|
"route": module.Route,
|
|
"type": "builtin",
|
|
"enabled": module.Enabled,
|
|
})
|
|
}
|
|
return items
|
|
}
|
|
|
|
func aiAppPayloads(caps []store.AICapability) []map[string]any {
|
|
routes := map[string]string{
|
|
"smart_document": "/workbench/ai/document",
|
|
"smart_table": "/workbench/ai/table",
|
|
}
|
|
items := make([]map[string]any, 0, len(caps))
|
|
for _, cap := range caps {
|
|
route := routes[cap.Feature]
|
|
if route == "" {
|
|
continue
|
|
}
|
|
cfg := jsonObject(cap.ConfigJSON)
|
|
title, _ := cfg["entry_title"].(string)
|
|
items = append(items, map[string]any{
|
|
"app_id": cap.Feature,
|
|
"name": firstNonEmpty(title, cap.Feature),
|
|
"icon": "ai",
|
|
"route": route,
|
|
"type": "ai",
|
|
"enabled": cap.Enabled,
|
|
})
|
|
}
|
|
return items
|
|
}
|
|
|
|
func hashText(text string) string {
|
|
sum := sha256.Sum256([]byte(text))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func excerpt(text string, maxRunes int) string {
|
|
runes := []rune(strings.TrimSpace(text))
|
|
if len(runes) <= maxRunes {
|
|
return string(runes)
|
|
}
|
|
return string(runes[:maxRunes])
|
|
}
|