1211 lines
42 KiB
Go
1211 lines
42 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"archive/zip"
|
||
"bytes"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"encoding/xml"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"strconv"
|
||
"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: strconv.FormatInt(p.UserID, 10), 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: strconv.FormatInt(p.UserID, 10), 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
|
||
}
|
||
var body aiTableExportRequest
|
||
if !decodeBody(w, r, &body) {
|
||
return
|
||
}
|
||
format := strings.ToLower(strings.TrimSpace(body.Format))
|
||
if format == "" {
|
||
format = "docx"
|
||
}
|
||
if format != "docx" {
|
||
writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "format", "supported": []string{"docx"}})
|
||
return
|
||
}
|
||
item, err := st.AITableAnalysis(r.Context(), p.UserID, recordID)
|
||
if err != nil {
|
||
writeStoreError(w, r, err)
|
||
return
|
||
}
|
||
fileName := aiTableExportFileName(item)
|
||
writeSuccess(w, r, map[string]any{
|
||
"file_id": "ai_export_" + recordID,
|
||
"file_name": fileName,
|
||
"download_url": absoluteURL(r, "/v1/workbench/ai/table-analyses/"+recordID+"/export/download?format=docx"),
|
||
"expires_at": time.Now().UTC().Add(24 * time.Hour).Format(time.RFC3339Nano),
|
||
})
|
||
return
|
||
}
|
||
if tail == "export/download" && r.Method == http.MethodGet {
|
||
format := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("format")))
|
||
if format == "" {
|
||
format = "docx"
|
||
}
|
||
if format != "docx" {
|
||
writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "format", "supported": []string{"docx"}})
|
||
return
|
||
}
|
||
item, err := st.AITableAnalysis(r.Context(), p.UserID, recordID)
|
||
if err != nil {
|
||
writeStoreError(w, r, err)
|
||
return
|
||
}
|
||
data, err := aiTableAnalysisDocx(item)
|
||
if err != nil {
|
||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||
return
|
||
}
|
||
fileName := aiTableExportFileName(item)
|
||
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")
|
||
w.Header().Set("Content-Disposition", contentDispositionAttachment(fileName))
|
||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write(data)
|
||
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: strconv.FormatInt(p.UserID, 10), 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 aiTableExportRequest struct {
|
||
Format string `json:"format"`
|
||
}
|
||
|
||
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"},
|
||
"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 aiTableAnalysisDocx(item *store.AITableAnalysis) ([]byte, error) {
|
||
var buf bytes.Buffer
|
||
zw := zip.NewWriter(&buf)
|
||
files := map[string]string{
|
||
"[Content_Types].xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||
<Default Extension="xml" ContentType="application/xml"/>
|
||
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
|
||
<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
|
||
</Types>`,
|
||
"_rels/.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
|
||
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
|
||
</Relationships>`,
|
||
"docProps/app.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"><Application>IM Workbench</Application></Properties>`,
|
||
"docProps/core.xml": aiDocxCoreXML(item),
|
||
"word/document.xml": aiTableAnalysisDocumentXML(item),
|
||
}
|
||
for name, content := range files {
|
||
w, err := zw.Create(name)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if _, err := w.Write([]byte(content)); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
if err := zw.Close(); err != nil {
|
||
return nil, err
|
||
}
|
||
return buf.Bytes(), nil
|
||
}
|
||
|
||
func aiTableAnalysisDocumentXML(item *store.AITableAnalysis) string {
|
||
var b strings.Builder
|
||
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
|
||
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>`)
|
||
docxParagraph(&b, firstNonEmpty(item.Topic, "智能表格解读报告"), true)
|
||
docxParagraph(&b, "文件信息", true)
|
||
docxParagraph(&b, "文件名:"+item.FileName, false)
|
||
docxParagraph(&b, "文件类型:"+item.FileType, false)
|
||
docxParagraph(&b, "文件大小:"+strconv.FormatInt(item.FileSize, 10)+" bytes", false)
|
||
docxParagraph(&b, "核心指标", true)
|
||
for _, metric := range aiExportList(item.MetricsJSON) {
|
||
docxParagraph(&b, "- "+aiExportMetricText(metric), false)
|
||
}
|
||
docxParagraph(&b, "异常提醒", true)
|
||
for _, alert := range aiExportList(item.AlertsJSON) {
|
||
docxParagraph(&b, "- "+aiExportAlertText(alert), false)
|
||
}
|
||
docxParagraph(&b, "核心解读", true)
|
||
for _, line := range strings.Split(strings.TrimSpace(item.Interpretation), "\n") {
|
||
if strings.TrimSpace(line) != "" {
|
||
docxParagraph(&b, strings.TrimSpace(line), false)
|
||
}
|
||
}
|
||
b.WriteString(`<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/></w:sectPr>`)
|
||
b.WriteString(`</w:body></w:document>`)
|
||
return b.String()
|
||
}
|
||
|
||
func docxParagraph(b *strings.Builder, text string, bold bool) {
|
||
b.WriteString(`<w:p><w:r>`)
|
||
if bold {
|
||
b.WriteString(`<w:rPr><w:b/></w:rPr>`)
|
||
}
|
||
b.WriteString(`<w:t>`)
|
||
_ = xml.EscapeText(b, []byte(text))
|
||
b.WriteString(`</w:t></w:r></w:p>`)
|
||
}
|
||
|
||
func aiDocxCoreXML(item *store.AITableAnalysis) string {
|
||
created := item.CreatedAt.UTC().Format(time.RFC3339)
|
||
title := xmlEscape(firstNonEmpty(item.Topic, "智能表格解读报告"))
|
||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||
<dc:title>` + title + `</dc:title><dc:creator>IM Workbench</dc:creator><cp:lastModifiedBy>IM Workbench</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">` + created + `</dcterms:created><dcterms:modified xsi:type="dcterms:W3CDTF">` + time.Now().UTC().Format(time.RFC3339) + `</dcterms:modified></cp:coreProperties>`
|
||
}
|
||
|
||
func aiExportList(raw string) []map[string]any {
|
||
var items []map[string]any
|
||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||
return nil
|
||
}
|
||
return items
|
||
}
|
||
|
||
func aiExportMetricText(item map[string]any) string {
|
||
return strings.TrimSpace(fmt.Sprintf("%v:%v(%v,%v)", item["name"], item["value"], item["change_text"], item["trend"]))
|
||
}
|
||
|
||
func aiExportAlertText(item map[string]any) string {
|
||
return strings.TrimSpace(fmt.Sprintf("[%v] %v:%v", item["level"], item["title"], item["description"]))
|
||
}
|
||
|
||
func aiTableExportFileName(item *store.AITableAnalysis) string {
|
||
name := strings.TrimSpace(firstNonEmpty(item.Topic, item.RecordID, "智能表格解读报告"))
|
||
replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_")
|
||
name = replacer.Replace(name)
|
||
if len([]rune(name)) > 80 {
|
||
name = string([]rune(name)[:80])
|
||
}
|
||
return name + "-解读报告.docx"
|
||
}
|
||
|
||
func absoluteURL(r *http.Request, path string) string {
|
||
scheme := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
|
||
if scheme == "" {
|
||
if r.TLS != nil {
|
||
scheme = "https"
|
||
} else {
|
||
scheme = "http"
|
||
}
|
||
}
|
||
host := strings.TrimSpace(r.Header.Get("X-Forwarded-Host"))
|
||
if host == "" {
|
||
host = r.Host
|
||
}
|
||
return scheme + "://" + host + path
|
||
}
|
||
|
||
func contentDispositionAttachment(fileName string) string {
|
||
escaped := url.QueryEscape(fileName)
|
||
return `attachment; filename="report.docx"; filename*=UTF-8''` + escaped
|
||
}
|
||
|
||
func xmlEscape(value string) string {
|
||
var b strings.Builder
|
||
_ = xml.EscapeText(&b, []byte(value))
|
||
return b.String()
|
||
}
|
||
|
||
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])
|
||
}
|