首次提交:初始化项目代码
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"resume-platform/internal/ai"
|
||||
"resume-platform/internal/prompt"
|
||||
)
|
||||
|
||||
type AIService struct {
|
||||
provider ai.Provider
|
||||
}
|
||||
|
||||
func NewAIService(provider ai.Provider) *AIService {
|
||||
return &AIService{provider: provider}
|
||||
}
|
||||
|
||||
func (s *AIService) PolishSummary(ctx context.Context, original string) (string, error) {
|
||||
promptText := prompt.BuildPolishSummaryPrompt(original)
|
||||
|
||||
return s.provider.Generate(ctx, promptText)
|
||||
}
|
||||
|
||||
func (s *AIService) PolishExperience(ctx context.Context, original string, company string, position string) (string, error) {
|
||||
promptText := prompt.BuildPolishExperiencePrompt(original, company, position)
|
||||
|
||||
return s.provider.Generate(ctx, promptText)
|
||||
}
|
||||
|
||||
func (s *AIService) PolishProject(ctx context.Context, original string, projectName string) (string, error) {
|
||||
promptText := prompt.BuildPolishProjectPrompt(original, projectName)
|
||||
|
||||
return s.provider.Generate(ctx, promptText)
|
||||
}
|
||||
|
||||
func (s *AIService) PolishDescription(ctx context.Context, original string, fieldType string) (string, error) {
|
||||
promptText := prompt.BuildPolishDescriptionPrompt(original, fieldType)
|
||||
|
||||
return s.provider.Generate(ctx, promptText)
|
||||
}
|
||||
|
||||
func (s *AIService) GetProviderName() string {
|
||||
return s.provider.GetName()
|
||||
}
|
||||
|
||||
func (s *AIService) Generate(ctx context.Context, prompt string) (string, error) {
|
||||
return s.provider.Generate(ctx, prompt)
|
||||
}
|
||||
|
||||
func (s *AIService) Embedding(ctx context.Context, text string) ([]float64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"resume-platform/internal/ai"
|
||||
"resume-platform/internal/prompt"
|
||||
"resume-platform/pkg/logger"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type QuestionGeneratorService struct {
|
||||
provider ai.Provider
|
||||
}
|
||||
|
||||
func NewQuestionGeneratorService(provider ai.Provider) *QuestionGeneratorService {
|
||||
return &QuestionGeneratorService{provider: provider}
|
||||
}
|
||||
|
||||
func (s *QuestionGeneratorService) GenerateQuestions(ctx context.Context, resumeText, keywordsStr, typesStr string) ([]map[string]interface{}, error) {
|
||||
if resumeText == "" && keywordsStr == "" {
|
||||
return nil, fmt.Errorf("resume_text or keywords is required")
|
||||
}
|
||||
|
||||
if resumeText == "" {
|
||||
resumeText = "基于关键词:" + keywordsStr
|
||||
}
|
||||
|
||||
techKeywords := extractTechKeywords(resumeText)
|
||||
if len(techKeywords) == 0 {
|
||||
techKeywords = []string{"HTML", "CSS", "JavaScript", "Go", "MySQL", "Redis"}
|
||||
}
|
||||
|
||||
if keywordsStr != "" {
|
||||
techKeywords = strings.Split(keywordsStr, ",")
|
||||
}
|
||||
|
||||
selectedTypes := parseTypes(typesStr)
|
||||
|
||||
logger.CtxInfof(ctx, "[QuestionGenerator] Generating questions for keywords: %v, types: %v", techKeywords, selectedTypes)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
resultChan := make(chan []map[string]interface{}, len(selectedTypes))
|
||||
errChan := make(chan error, len(selectedTypes))
|
||||
|
||||
for _, qType := range selectedTypes {
|
||||
wg.Add(1)
|
||||
go func(t string) {
|
||||
defer wg.Done()
|
||||
|
||||
prompt := buildQuestionGenerationPromptForType(resumeText, techKeywords, t)
|
||||
logger.CtxInfof(ctx, "[QuestionGenerator] Generating %s questions...", t)
|
||||
|
||||
result, err := s.provider.Generate(ctx, prompt)
|
||||
if err != nil {
|
||||
logger.CtxErrorf(ctx, "[QuestionGenerator] AI generate failed for %s: %v", t, err)
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
|
||||
questions, err := parseAIQuestionsWithCtx(ctx, result)
|
||||
if err != nil {
|
||||
logger.CtxErrorf(ctx, "[QuestionGenerator] Parse failed for %s: %v", t, err)
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
|
||||
if len(questions) > 0 {
|
||||
resultChan <- questions
|
||||
logger.CtxInfof(ctx, "[QuestionGenerator] Generated %d %s questions", len(questions), t)
|
||||
}
|
||||
}(qType)
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(resultChan)
|
||||
close(errChan)
|
||||
}()
|
||||
|
||||
var allQuestions []map[string]interface{}
|
||||
for questions := range resultChan {
|
||||
allQuestions = append(allQuestions, questions...)
|
||||
}
|
||||
|
||||
logger.CtxInfof(ctx, "[QuestionGenerator] Total generated %d questions", len(allQuestions))
|
||||
|
||||
if len(allQuestions) == 0 {
|
||||
return nil, fmt.Errorf("未能生成任何题目")
|
||||
}
|
||||
|
||||
return allQuestions, nil
|
||||
}
|
||||
|
||||
func buildQuestionGenerationPrompt(resumeText string, keywords []string, types []string) string {
|
||||
return prompt.BuildQuestionGenerationPrompt(resumeText, keywords, types)
|
||||
}
|
||||
|
||||
func buildQuestionGenerationPromptForType(resumeText string, keywords []string, qType string) string {
|
||||
return prompt.BuildQuestionGenerationPromptForType(resumeText, keywords, qType)
|
||||
}
|
||||
|
||||
func parseAIQuestionsWithCtx(ctx context.Context, result string) ([]map[string]interface{}, error) {
|
||||
result = strings.TrimSpace(result)
|
||||
|
||||
logger.CtxInfof(ctx, "[QuestionGenerator] AI response length: %d", len(result))
|
||||
logger.CtxInfof(ctx, "[QuestionGenerator] AI response raw result (first 2000 chars): %s", substr(result, 0, 2000))
|
||||
|
||||
result = strings.ReplaceAll(result, "```json", "")
|
||||
result = strings.ReplaceAll(result, "```", "")
|
||||
result = strings.TrimSpace(result)
|
||||
|
||||
startIdx := strings.Index(result, "[")
|
||||
endIdx := strings.LastIndex(result, "]")
|
||||
if startIdx == -1 || endIdx == -1 || endIdx <= startIdx {
|
||||
logger.CtxErrorf(ctx, "[QuestionGenerator] Cannot find JSON array boundaries")
|
||||
return nil, fmt.Errorf("无法找到JSON数组边界")
|
||||
}
|
||||
|
||||
jsonStr := result[startIdx : endIdx+1]
|
||||
logger.CtxInfof(ctx, "[QuestionGenerator] Extracted JSON array (length=%d)", len(jsonStr))
|
||||
|
||||
var questions []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(jsonStr), &questions); err != nil {
|
||||
logger.CtxErrorf(ctx, "[QuestionGenerator] Failed to unmarshal as array: %v", err)
|
||||
logger.CtxErrorf(ctx, "[QuestionGenerator] Raw result that failed (first 500 chars): %s", substr(jsonStr, 0, 500))
|
||||
return nil, fmt.Errorf("AI返回的JSON格式不正确: %w", err)
|
||||
}
|
||||
|
||||
var validQuestions []map[string]interface{}
|
||||
for _, q := range questions {
|
||||
if !isValidQuestion(q) {
|
||||
logger.CtxWarnf(ctx, "[QuestionGenerator] Skipping invalid question: %v", q)
|
||||
continue
|
||||
}
|
||||
|
||||
q = normalizeQuestion(q)
|
||||
validQuestions = append(validQuestions, q)
|
||||
}
|
||||
|
||||
logger.CtxInfof(ctx, "[QuestionGenerator] Successfully parsed %d valid questions (filtered %d invalid)", len(validQuestions), len(questions)-len(validQuestions))
|
||||
|
||||
if len(validQuestions) == 0 {
|
||||
return nil, fmt.Errorf("未解析到有效题目")
|
||||
}
|
||||
|
||||
return validQuestions, nil
|
||||
}
|
||||
|
||||
func isValidQuestion(q map[string]interface{}) bool {
|
||||
if q == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
qType := fmt.Sprintf("%v", q["type"])
|
||||
text := fmt.Sprintf("%v", q["text"])
|
||||
answer := fmt.Sprintf("%v", q["answer"])
|
||||
|
||||
if qType == "" || text == "" || answer == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
if qType == "mcq" {
|
||||
if options, ok := q["options"].([]interface{}); ok {
|
||||
if len(options) != 4 {
|
||||
return false
|
||||
}
|
||||
for _, opt := range options {
|
||||
if fmt.Sprintf("%v", opt) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
answer = strings.ToUpper(strings.TrimSpace(answer))
|
||||
if len(answer) != 1 || (answer < "A" || answer > "D") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if qType == "fill" {
|
||||
if !strings.Contains(text, "___") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if qType == "sa" || qType == "algo" {
|
||||
if len(answer) < 10 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
analysis := fmt.Sprintf("%v", q["analysis"])
|
||||
if len(analysis) < 30 {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeQuestion(q map[string]interface{}) map[string]interface{} {
|
||||
qType := fmt.Sprintf("%v", q["type"])
|
||||
|
||||
if qType == "mcq" {
|
||||
answer := strings.TrimSpace(fmt.Sprintf("%v", q["answer"]))
|
||||
if len(answer) > 0 {
|
||||
q["answer"] = strings.ToUpper(answer[:1])
|
||||
}
|
||||
|
||||
if options, ok := q["options"].([]interface{}); ok {
|
||||
var optionStrings []string
|
||||
for _, opt := range options {
|
||||
optStr := strings.TrimSpace(fmt.Sprintf("%v", opt))
|
||||
optionStrings = append(optionStrings, optStr)
|
||||
}
|
||||
q["options"] = optionStrings
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := q["score"]; !ok {
|
||||
diff := fmt.Sprintf("%v", q["difficulty"])
|
||||
q["score"] = getScoreByDifficulty(diff)
|
||||
}
|
||||
if _, ok := q["category"]; !ok {
|
||||
q["category"] = "综合"
|
||||
}
|
||||
if _, ok := q["keywords"]; !ok {
|
||||
q["keywords"] = []string{}
|
||||
}
|
||||
if _, ok := q["options"]; !ok {
|
||||
q["options"] = []string{}
|
||||
}
|
||||
if _, ok := q["analysis"]; !ok {
|
||||
q["analysis"] = ""
|
||||
}
|
||||
if _, ok := q["difficulty"]; !ok {
|
||||
q["difficulty"] = "初级"
|
||||
}
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
func isSingleLetter(s string) bool {
|
||||
return len(s) == 1 && (s >= "A" && s <= "Z" || s >= "a" && s <= "z")
|
||||
}
|
||||
|
||||
func substr(s string, start, length int) string {
|
||||
if len(s) <= start {
|
||||
return ""
|
||||
}
|
||||
end := start + length
|
||||
if end > len(s) {
|
||||
end = len(s)
|
||||
}
|
||||
return s[start:end]
|
||||
}
|
||||
|
||||
func extractTechKeywords(text string) []string {
|
||||
techPatterns := []string{"Go", "Golang", "Java", "Python", "JavaScript", "TypeScript", "React", "Vue", "MySQL", "PostgreSQL", "Redis", "MongoDB", "Kafka", "Docker", "Kubernetes", "微服务", "分布式"}
|
||||
var result []string
|
||||
for _, pattern := range techPatterns {
|
||||
if strings.Contains(text, pattern) {
|
||||
result = append(result, pattern)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseTypes(typesStr string) []string {
|
||||
if typesStr == "" {
|
||||
return []string{"mcq", "fill", "sa", "algo"}
|
||||
}
|
||||
return strings.Split(typesStr, ",")
|
||||
}
|
||||
|
||||
func getScoreByDifficulty(difficulty string) int {
|
||||
switch difficulty {
|
||||
case "入门":
|
||||
return 5
|
||||
case "初级":
|
||||
return 10
|
||||
case "中级":
|
||||
return 15
|
||||
case "进阶":
|
||||
return 20
|
||||
case "高级":
|
||||
return 25
|
||||
default:
|
||||
return 10
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/prompt"
|
||||
"resume-platform/internal/repository"
|
||||
"resume-platform/internal/service/ai"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ChatService interface {
|
||||
Chat(ctx context.Context, userID string, question string) (string, error)
|
||||
GetChatHistory(ctx context.Context, userID string) ([]*model.ChatHistory, error)
|
||||
DeleteChatHistory(ctx context.Context, userID string) error
|
||||
}
|
||||
|
||||
type chatService struct {
|
||||
repo repository.ResumeRepository
|
||||
ai *ai.AIService
|
||||
}
|
||||
|
||||
func NewChatService(repo repository.ResumeRepository, ai *ai.AIService) ChatService {
|
||||
return &chatService{repo: repo, ai: ai}
|
||||
}
|
||||
|
||||
func (s *chatService) Chat(ctx context.Context, userID string, question string) (string, error) {
|
||||
chunks, err := s.repo.GetDocumentChunksByUserID(userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(chunks) == 0 {
|
||||
return s.ai.Generate(ctx, question)
|
||||
}
|
||||
|
||||
var chunkList []model.ChunkWithScore
|
||||
for _, chunk := range chunks {
|
||||
chunkList = append(chunkList, model.ChunkWithScore{
|
||||
Content: chunk.Content,
|
||||
Metadata: chunk.Embedding,
|
||||
})
|
||||
}
|
||||
|
||||
var contextStr string
|
||||
for i, chunk := range chunkList[:min(5, len(chunkList))] {
|
||||
contextStr += fmt.Sprintf("[文档片段%d]\n%s\n\n", i+1, chunk.Content)
|
||||
}
|
||||
|
||||
promptText := prompt.BuildDocumentAnalysisPrompt(contextStr, question)
|
||||
|
||||
answer, err := s.ai.Generate(ctx, promptText)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
history := &model.ChatHistory{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Question: question,
|
||||
Answer: answer,
|
||||
Context: contextStr,
|
||||
}
|
||||
s.repo.CreateChatHistory(history)
|
||||
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
func (s *chatService) GetChatHistory(ctx context.Context, userID string) ([]*model.ChatHistory, error) {
|
||||
return s.repo.GetChatHistoryByUserID(userID)
|
||||
}
|
||||
|
||||
func (s *chatService) DeleteChatHistory(ctx context.Context, userID string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"resume-platform/internal/agent"
|
||||
"resume-platform/internal/ai"
|
||||
)
|
||||
|
||||
type IMService struct {
|
||||
aiProvider ai.Provider
|
||||
resumeAgent *agent.ResumeAgent
|
||||
}
|
||||
|
||||
func NewIMService(provider ai.Provider, resumeAgent *agent.ResumeAgent) *IMService {
|
||||
return &IMService{aiProvider: provider, resumeAgent: resumeAgent}
|
||||
}
|
||||
|
||||
func (s *IMService) GetAIProvider() ai.Provider {
|
||||
return s.aiProvider
|
||||
}
|
||||
|
||||
func (s *IMService) GetResumeAgent() *agent.ResumeAgent {
|
||||
return s.resumeAgent
|
||||
}
|
||||
|
||||
func (s *IMService) GenerateWithAgent(ctx context.Context, query string) (string, error) {
|
||||
if s.resumeAgent != nil {
|
||||
return s.resumeAgent.Run(ctx, query)
|
||||
}
|
||||
if s.aiProvider != nil {
|
||||
return s.aiProvider.Generate(ctx, query)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package document
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/parser"
|
||||
"resume-platform/internal/repository"
|
||||
"resume-platform/internal/service/knowledge"
|
||||
"resume-platform/internal/service/resume"
|
||||
"resume-platform/pkg/logger"
|
||||
)
|
||||
|
||||
type DocumentService interface {
|
||||
UploadDocument(ctx context.Context, userID string, fileName string, fileType string, fileSize int64, content string) (*model.Document, error)
|
||||
GetDocuments(ctx context.Context, userID string) ([]*model.Document, error)
|
||||
GetDocument(ctx context.Context, userID string, documentID string) (*model.Document, error)
|
||||
DeleteDocument(ctx context.Context, userID string, documentID string) error
|
||||
ProcessDocument(ctx context.Context, documentID string) error
|
||||
ParseAndGenerateResume(ctx context.Context, userID string, fileName string, content []byte) (*model.Resume, error)
|
||||
}
|
||||
|
||||
type documentService struct {
|
||||
repo repository.ResumeRepository
|
||||
resumeGenerator *resume.ResumeGeneratorService
|
||||
knowledgeBaseService *knowledge.KnowledgeBaseService
|
||||
}
|
||||
|
||||
func NewDocumentService(repo repository.ResumeRepository, resumeGenerator *resume.ResumeGeneratorService) DocumentService {
|
||||
return &documentService{repo: repo, resumeGenerator: resumeGenerator}
|
||||
}
|
||||
|
||||
func NewDocumentServiceWithKnowledge(repo repository.ResumeRepository, resumeGenerator *resume.ResumeGeneratorService, knowledgeBaseService *knowledge.KnowledgeBaseService) DocumentService {
|
||||
return &documentService{repo: repo, resumeGenerator: resumeGenerator, knowledgeBaseService: knowledgeBaseService}
|
||||
}
|
||||
|
||||
func (s *documentService) UploadDocument(ctx context.Context, userID string, fileName string, fileType string, fileSize int64, content string) (*model.Document, error) {
|
||||
document := &model.Document{
|
||||
ID: generateID(),
|
||||
UserID: userID,
|
||||
Name: fileName,
|
||||
Type: fileType,
|
||||
Size: fileSize,
|
||||
Content: content,
|
||||
Status: "uploading",
|
||||
}
|
||||
|
||||
err := s.repo.CreateDocument(document)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go s.ProcessDocument(ctx, document.ID)
|
||||
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func (s *documentService) GetDocuments(ctx context.Context, userID string) ([]*model.Document, error) {
|
||||
return s.repo.GetDocumentsByUserID(userID)
|
||||
}
|
||||
|
||||
func (s *documentService) GetDocument(ctx context.Context, userID string, documentID string) (*model.Document, error) {
|
||||
return s.repo.GetDocument(documentID, userID)
|
||||
}
|
||||
|
||||
func (s *documentService) DeleteDocument(ctx context.Context, userID string, documentID string) error {
|
||||
return s.repo.DeleteDocument(documentID, userID)
|
||||
}
|
||||
|
||||
func (s *documentService) ProcessDocument(ctx context.Context, documentID string) error {
|
||||
document, err := s.repo.GetDocumentByID(documentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if document.Content == "" {
|
||||
document.Status = "failed"
|
||||
document.ErrorMsg = "document content is empty"
|
||||
return s.repo.UpdateDocument(document)
|
||||
}
|
||||
|
||||
chunks := s.splitText(document.Content, 512)
|
||||
|
||||
for i, chunk := range chunks {
|
||||
metadata := map[string]interface{}{
|
||||
"document_id": document.ID,
|
||||
"chunk_index": i,
|
||||
"user_id": document.UserID,
|
||||
}
|
||||
metadataJSON, _ := json.Marshal(metadata)
|
||||
|
||||
documentChunk := &model.DocumentChunk{
|
||||
ID: generateID(),
|
||||
DocumentID: document.ID,
|
||||
ChunkIndex: i,
|
||||
Content: chunk,
|
||||
Metadata: string(metadataJSON),
|
||||
}
|
||||
|
||||
err := s.repo.CreateDocumentChunk(documentChunk)
|
||||
if err != nil {
|
||||
document.Status = "failed"
|
||||
document.ErrorMsg = err.Error()
|
||||
s.repo.UpdateDocument(document)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if s.knowledgeBaseService != nil {
|
||||
err = s.knowledgeBaseService.AddDocument(ctx, document.ID, document.Content, document.UserID)
|
||||
if err != nil {
|
||||
logger.Warnf("Failed to add document to knowledge base: %v", err)
|
||||
} else {
|
||||
logger.Infof("Document %s added to knowledge base", document.ID)
|
||||
}
|
||||
}
|
||||
|
||||
document.Status = "processed"
|
||||
return s.repo.UpdateDocument(document)
|
||||
}
|
||||
|
||||
func (s *documentService) splitText(text string, chunkSize int) []string {
|
||||
var chunks []string
|
||||
words := []rune(text)
|
||||
|
||||
for i := 0; i < len(words); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(words) {
|
||||
end = len(words)
|
||||
}
|
||||
chunks = append(chunks, string(words[i:end]))
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
func generateID() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
func (s *documentService) ParseAndGenerateResume(ctx context.Context, userID string, fileName string, content []byte) (*model.Resume, error) {
|
||||
documentText, err := parser.ParseFile(fileName, bytes.NewReader(content))
|
||||
if err != nil {
|
||||
logger.Warnf("Failed to parse document %s: %v", fileName, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if documentText == "" {
|
||||
return nil, fmt.Errorf("document content is empty after parsing")
|
||||
}
|
||||
|
||||
resume, err := s.resumeGenerator.GenerateFromDocument(ctx, documentText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resume.UserID = userID
|
||||
fmt.Printf("DEBUG document.go: resume.UserID set to: '%s'\n", resume.UserID)
|
||||
|
||||
return resume, nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"resume-platform/internal/ai"
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/repository"
|
||||
)
|
||||
|
||||
type EmbeddingService struct {
|
||||
aiProvider ai.Provider
|
||||
repo repository.ResumeRepository
|
||||
}
|
||||
|
||||
func NewEmbeddingService(provider ai.Provider, repo repository.ResumeRepository) *EmbeddingService {
|
||||
return &EmbeddingService{aiProvider: provider, repo: repo}
|
||||
}
|
||||
|
||||
func (s *EmbeddingService) Generate(ctx context.Context, text string) ([]float64, error) {
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("empty text")
|
||||
}
|
||||
|
||||
contentHash := s.hashContent(text)
|
||||
|
||||
cachedEmbedding, err := s.repo.GetEmbeddingByContentHash(contentHash)
|
||||
if err == nil && cachedEmbedding != nil && cachedEmbedding.Embedding != "" {
|
||||
var embedding []float64
|
||||
if err := json.Unmarshal([]byte(cachedEmbedding.Embedding), &embedding); err == nil && len(embedding) > 0 {
|
||||
return embedding, nil
|
||||
}
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(`请将以下文本转换为向量嵌入。直接输出JSON数组,不要包含任何其他内容。
|
||||
|
||||
文本内容:
|
||||
%s`, text)
|
||||
|
||||
result, err := s.aiProvider.Generate(ctx, prompt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result = cleanEmbeddingResult(result)
|
||||
|
||||
var embedding []float64
|
||||
if err := json.Unmarshal([]byte(result), &embedding); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse embedding: %w", err)
|
||||
}
|
||||
|
||||
return embedding, nil
|
||||
}
|
||||
|
||||
func (s *EmbeddingService) GenerateAndStore(ctx context.Context, text, sourceType, sourceID, sourceName, userID string) ([]float64, error) {
|
||||
embedding, err := s.Generate(ctx, text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
contentHash := s.hashContent(text)
|
||||
embeddingJSON, _ := json.Marshal(embedding)
|
||||
|
||||
err = s.repo.CreateEmbedding(&model.Embedding{
|
||||
ID: uuid.New().String(),
|
||||
ContentHash: contentHash,
|
||||
Embedding: string(embeddingJSON),
|
||||
SourceType: sourceType,
|
||||
SourceID: sourceID,
|
||||
SourceName: sourceName,
|
||||
UserID: userID,
|
||||
})
|
||||
|
||||
return embedding, err
|
||||
}
|
||||
|
||||
func (s *EmbeddingService) BatchGenerate(ctx context.Context, texts []string) ([][]float64, error) {
|
||||
var results [][]float64
|
||||
for _, text := range texts {
|
||||
embedding, err := s.Generate(ctx, text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, embedding)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *EmbeddingService) hashContent(content string) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(content))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
func cleanEmbeddingResult(result string) string {
|
||||
result = trimString(result)
|
||||
|
||||
if idx := findFirstIndex(result, '['); idx >= 0 {
|
||||
result = result[idx:]
|
||||
}
|
||||
if idx := findLastIndex(result, ']'); idx >= 0 {
|
||||
result = result[:idx+1]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func trimString(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
func findFirstIndex(s string, c byte) int {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == c {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func findLastIndex(s string, c byte) int {
|
||||
for i := len(s) - 1; i >= 0; i-- {
|
||||
if s[i] == c {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/repository"
|
||||
"resume-platform/pkg/logger"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type KnowledgeBaseService struct {
|
||||
embeddingService *EmbeddingService
|
||||
vectorService *VectorService
|
||||
repo repository.ResumeRepository
|
||||
}
|
||||
|
||||
func NewKnowledgeBaseService(embeddingService *EmbeddingService, vectorService *VectorService, repo repository.ResumeRepository) *KnowledgeBaseService {
|
||||
return &KnowledgeBaseService{
|
||||
embeddingService: embeddingService,
|
||||
vectorService: vectorService,
|
||||
repo: repo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) AddDocument(ctx context.Context, documentID, content, userID string) error {
|
||||
if content == "" {
|
||||
return fmt.Errorf("empty content")
|
||||
}
|
||||
|
||||
chunks := s.chunkContent(content, 500, 50)
|
||||
|
||||
doc, _ := s.repo.GetDocumentByID(documentID)
|
||||
sourceName := documentID[:8]
|
||||
if doc != nil && doc.Name != "" {
|
||||
sourceName = doc.Name
|
||||
}
|
||||
|
||||
for i, chunk := range chunks {
|
||||
documentChunk := &model.DocumentChunk{
|
||||
ID: uuid.New().String(),
|
||||
DocumentID: documentID,
|
||||
ChunkIndex: i,
|
||||
Content: chunk,
|
||||
Metadata: "",
|
||||
}
|
||||
|
||||
err := s.vectorService.StoreChunk(ctx, documentChunk, userID, sourceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("Added %d chunks to document %s", len(chunks), documentID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) AddResume(ctx context.Context, resumeID string, resume *model.Resume) error {
|
||||
if resume == nil {
|
||||
return fmt.Errorf("nil resume")
|
||||
}
|
||||
|
||||
resumeText := s.serializeResume(resume)
|
||||
if resumeText == "" {
|
||||
return fmt.Errorf("empty resume content")
|
||||
}
|
||||
|
||||
chunks := s.chunkContent(resumeText, 500, 50)
|
||||
sourceName := resume.BasicInfo.Name
|
||||
if sourceName == "" {
|
||||
sourceName = resumeID[:8]
|
||||
}
|
||||
|
||||
for i, chunk := range chunks {
|
||||
documentChunk := &model.DocumentChunk{
|
||||
ID: uuid.New().String(),
|
||||
DocumentID: resumeID,
|
||||
ChunkIndex: i,
|
||||
Content: chunk,
|
||||
Metadata: "",
|
||||
}
|
||||
|
||||
err := s.vectorService.StoreChunk(ctx, documentChunk, resume.UserID, sourceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err := s.vectorService.Store(ctx, resumeText, "resume", resumeID, sourceName, resume.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Infof("Added resume %s to knowledge base with %d chunks", resumeID, len(chunks))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) AddText(ctx context.Context, text, sourceType, sourceID, sourceName, userID string) error {
|
||||
if text == "" {
|
||||
return fmt.Errorf("empty text")
|
||||
}
|
||||
|
||||
return s.vectorService.Store(ctx, text, sourceType, sourceID, sourceName, userID)
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) Retrieve(ctx context.Context, query string, topK int, userID string) ([]model.ChunkWithScore, error) {
|
||||
return s.vectorService.SearchWithChunks(ctx, query, topK, userID)
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) RetrieveAll(ctx context.Context, query string, topK int) ([]model.ChunkWithScore, error) {
|
||||
return s.vectorService.Search(ctx, query, topK, "")
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) DeleteDocument(ctx context.Context, documentID string) error {
|
||||
err := s.repo.DeleteEmbeddingsBySource("document", documentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.vectorService.DeleteDocumentChunks(ctx, documentID)
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) DeleteResume(ctx context.Context, resumeID string) error {
|
||||
return s.repo.DeleteEmbeddingsBySource("resume", resumeID)
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) GetStats(ctx context.Context, userID string) (map[string]int, error) {
|
||||
var stats = map[string]int{}
|
||||
|
||||
if userID != "" {
|
||||
embeddings, err := s.repo.GetEmbeddingsByUserID(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats["embeddings"] = len(embeddings)
|
||||
|
||||
chunks, err := s.repo.GetDocumentChunksByUserID(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats["chunks"] = len(chunks)
|
||||
} else {
|
||||
embeddings, err := s.repo.GetAllEmbeddings()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats["embeddings"] = len(embeddings)
|
||||
|
||||
chunks, err := s.repo.GetAllDocumentChunks()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats["chunks"] = len(chunks)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) chunkContent(content string, chunkSize, overlap int) []string {
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
content = strings.ReplaceAll(content, "\r\n", "\n")
|
||||
content = strings.ReplaceAll(content, "\r", "\n")
|
||||
|
||||
var chunks []string
|
||||
start := 0
|
||||
contentLen := len(content)
|
||||
|
||||
for start < contentLen {
|
||||
end := start + chunkSize
|
||||
if end > contentLen {
|
||||
end = contentLen
|
||||
}
|
||||
|
||||
if end < contentLen {
|
||||
for i := end; i > start && i > start+chunkSize-overlap; i-- {
|
||||
c := rune(content[i])
|
||||
if c == '\n' || c == ';' || c == '\r' {
|
||||
end = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chunk := strings.TrimSpace(content[start:end])
|
||||
if chunk != "" {
|
||||
chunks = append(chunks, chunk)
|
||||
}
|
||||
|
||||
start = end - overlap
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if start >= contentLen {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) serializeResume(resume *model.Resume) string {
|
||||
var builder strings.Builder
|
||||
|
||||
if resume.BasicInfo.Name != "" {
|
||||
builder.WriteString("姓名:")
|
||||
builder.WriteString(resume.BasicInfo.Name)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if resume.BasicInfo.Title != "" {
|
||||
builder.WriteString("职位:")
|
||||
builder.WriteString(resume.BasicInfo.Title)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if resume.BasicInfo.Email != "" {
|
||||
builder.WriteString("邮箱:")
|
||||
builder.WriteString(resume.BasicInfo.Email)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if resume.BasicInfo.Phone != "" {
|
||||
builder.WriteString("电话:")
|
||||
builder.WriteString(resume.BasicInfo.Phone)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if resume.BasicInfo.Location != "" {
|
||||
builder.WriteString("所在地:")
|
||||
builder.WriteString(resume.BasicInfo.Location)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if resume.BasicInfo.Summary != "" {
|
||||
builder.WriteString("个人简介:")
|
||||
builder.WriteString(resume.BasicInfo.Summary)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if resume.BasicInfo.JobTarget != "" {
|
||||
builder.WriteString("求职目标:")
|
||||
builder.WriteString(resume.BasicInfo.JobTarget)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(resume.Experience) > 0 {
|
||||
builder.WriteString("\n【工作经历】\n")
|
||||
for _, exp := range resume.Experience {
|
||||
builder.WriteString("公司:")
|
||||
builder.WriteString(exp.Company)
|
||||
builder.WriteString("\n职位:")
|
||||
builder.WriteString(exp.Position)
|
||||
builder.WriteString("\n时间:")
|
||||
builder.WriteString(exp.StartDate)
|
||||
if exp.EndDate != "" {
|
||||
builder.WriteString(" - ")
|
||||
builder.WriteString(exp.EndDate)
|
||||
}
|
||||
builder.WriteString("\n职责:")
|
||||
builder.WriteString(exp.Description)
|
||||
builder.WriteString("\n")
|
||||
if len(exp.Highlights) > 0 {
|
||||
builder.WriteString("亮点:")
|
||||
builder.WriteString(strings.Join(exp.Highlights, ";"))
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if len(resume.Education) > 0 {
|
||||
builder.WriteString("\n【教育背景】\n")
|
||||
for _, edu := range resume.Education {
|
||||
builder.WriteString("学校:")
|
||||
builder.WriteString(edu.School)
|
||||
builder.WriteString("\n学位:")
|
||||
builder.WriteString(edu.Degree)
|
||||
builder.WriteString("\n专业:")
|
||||
builder.WriteString(edu.Major)
|
||||
builder.WriteString("\n时间:")
|
||||
builder.WriteString(edu.StartDate)
|
||||
if edu.EndDate != "" {
|
||||
builder.WriteString(" - ")
|
||||
builder.WriteString(edu.EndDate)
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if len(resume.Skills) > 0 {
|
||||
builder.WriteString("\n【专业技能】\n")
|
||||
for _, skill := range resume.Skills {
|
||||
builder.WriteString(skill.Name)
|
||||
if skill.Level != "" {
|
||||
builder.WriteString("(")
|
||||
builder.WriteString(skill.Level)
|
||||
builder.WriteString(")")
|
||||
}
|
||||
if skill.Category != "" {
|
||||
builder.WriteString(" - ")
|
||||
builder.WriteString(skill.Category)
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if len(resume.Projects) > 0 {
|
||||
builder.WriteString("\n【项目经验】\n")
|
||||
for _, proj := range resume.Projects {
|
||||
builder.WriteString("项目名称:")
|
||||
builder.WriteString(proj.Name)
|
||||
builder.WriteString("\n描述:")
|
||||
builder.WriteString(proj.Description)
|
||||
builder.WriteString("\n")
|
||||
if len(proj.TechStack) > 0 {
|
||||
builder.WriteString("技术栈:")
|
||||
builder.WriteString(strings.Join(proj.TechStack, "、"))
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if len(proj.Highlights) > 0 {
|
||||
builder.WriteString("亮点:")
|
||||
builder.WriteString(strings.Join(proj.Highlights, ";"))
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if len(proj.Achievements) > 0 {
|
||||
builder.WriteString("成果:")
|
||||
builder.WriteString(strings.Join(proj.Achievements, ";"))
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
result := builder.String()
|
||||
if len(result) > 15000 {
|
||||
result = result[:15000]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) BuildIndex(ctx context.Context) error {
|
||||
documents, err := s.repo.GetAllDocumentChunks()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, chunk := range documents {
|
||||
if chunk.Embedding == "" && chunk.Content != "" {
|
||||
embedding, err := s.embeddingService.Generate(ctx, chunk.Content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
embeddingJSON, _ := json.Marshal(embedding)
|
||||
chunk.Embedding = string(embeddingJSON)
|
||||
err = s.repo.CreateDocumentChunk(chunk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"resume-platform/internal/ai"
|
||||
"resume-platform/internal/prompt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RAGService struct {
|
||||
aiProvider ai.Provider
|
||||
knowledgeBaseService *KnowledgeBaseService
|
||||
}
|
||||
|
||||
func NewRAGService(provider ai.Provider, knowledgeBaseService *KnowledgeBaseService) *RAGService {
|
||||
return &RAGService{aiProvider: provider, knowledgeBaseService: knowledgeBaseService}
|
||||
}
|
||||
|
||||
func (s *RAGService) Generate(ctx context.Context, query string, userID string) (string, error) {
|
||||
contextResults, err := s.knowledgeBaseService.Retrieve(ctx, query, 5, userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var contextStr string
|
||||
for _, result := range contextResults {
|
||||
contextStr += fmt.Sprintf("【来源:%s】\n%s\n\n", result.SourceName, result.Content)
|
||||
}
|
||||
|
||||
if contextStr == "" {
|
||||
return s.aiProvider.Generate(ctx, query)
|
||||
}
|
||||
|
||||
promptText := prompt.BuildRAGAnswerPrompt(contextStr, query)
|
||||
|
||||
return s.aiProvider.Generate(ctx, promptText)
|
||||
}
|
||||
|
||||
func (s *RAGService) GenerateWithContext(ctx context.Context, query string, context []string) (string, error) {
|
||||
contextStr := strings.Join(context, "\n\n")
|
||||
|
||||
promptText := prompt.BuildRAGAnswerPrompt(contextStr, query)
|
||||
|
||||
return s.aiProvider.Generate(ctx, promptText)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/repository"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type VectorService struct {
|
||||
embeddingService *EmbeddingService
|
||||
repo repository.ResumeRepository
|
||||
}
|
||||
|
||||
func NewVectorService(embeddingService *EmbeddingService, repo repository.ResumeRepository) *VectorService {
|
||||
return &VectorService{embeddingService: embeddingService, repo: repo}
|
||||
}
|
||||
|
||||
func (s *VectorService) Store(ctx context.Context, content, sourceType, sourceID, sourceName, userID string) error {
|
||||
_, err := s.embeddingService.GenerateAndStore(ctx, content, sourceType, sourceID, sourceName, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *VectorService) StoreChunk(ctx context.Context, chunk *model.DocumentChunk, userID string, sourceName string) error {
|
||||
if chunk.Content == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
embedding, err := s.embeddingService.Generate(ctx, chunk.Content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
embeddingJSON, _ := json.Marshal(embedding)
|
||||
chunk.Embedding = string(embeddingJSON)
|
||||
|
||||
err = s.repo.CreateDocumentChunk(chunk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
contentHash := s.embeddingService.hashContent(chunk.Content)
|
||||
err = s.repo.CreateEmbedding(&model.Embedding{
|
||||
ID: chunk.ID,
|
||||
ContentHash: contentHash,
|
||||
Embedding: string(embeddingJSON),
|
||||
SourceType: "document",
|
||||
SourceID: chunk.DocumentID,
|
||||
SourceName: sourceName,
|
||||
UserID: userID,
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *VectorService) Search(ctx context.Context, query string, topK int, userID string) ([]model.ChunkWithScore, error) {
|
||||
if query == "" {
|
||||
return nil, fmt.Errorf("empty query")
|
||||
}
|
||||
|
||||
queryEmbedding, err := s.embeddingService.Generate(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allEmbeddings []*model.Embedding
|
||||
if userID != "" {
|
||||
allEmbeddings, err = s.repo.GetEmbeddingsByUserID(userID)
|
||||
} else {
|
||||
allEmbeddings, err = s.repo.GetAllEmbeddings()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var results []model.ChunkWithScore
|
||||
for _, emb := range allEmbeddings {
|
||||
if emb.Embedding == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var embedding []float64
|
||||
if err := json.Unmarshal([]byte(emb.Embedding), &embedding); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
score := cosineSimilarity(queryEmbedding, embedding)
|
||||
if score > 0.3 {
|
||||
results = append(results, model.ChunkWithScore{
|
||||
Content: emb.SourceName,
|
||||
Score: score,
|
||||
SourceID: emb.SourceID,
|
||||
SourceName: emb.SourceName,
|
||||
SourceType: emb.SourceType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
return results[i].Score > results[j].Score
|
||||
})
|
||||
|
||||
if len(results) > topK {
|
||||
results = results[:topK]
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *VectorService) SearchWithChunks(ctx context.Context, query string, topK int, userID string) ([]model.ChunkWithScore, error) {
|
||||
if query == "" {
|
||||
return nil, fmt.Errorf("empty query")
|
||||
}
|
||||
|
||||
queryEmbedding, err := s.embeddingService.Generate(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allChunks []*model.DocumentChunk
|
||||
if userID != "" {
|
||||
allChunks, err = s.repo.GetDocumentChunksByUserID(userID)
|
||||
} else {
|
||||
allChunks, err = s.repo.GetAllDocumentChunks()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var results []model.ChunkWithScore
|
||||
for _, chunk := range allChunks {
|
||||
if chunk.Embedding == "" || chunk.Content == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var embedding []float64
|
||||
if err := json.Unmarshal([]byte(chunk.Embedding), &embedding); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
score := cosineSimilarity(queryEmbedding, embedding)
|
||||
if score > 0.3 {
|
||||
doc, _ := s.repo.GetDocumentByID(chunk.DocumentID)
|
||||
sourceName := ""
|
||||
if doc != nil {
|
||||
sourceName = doc.Name
|
||||
}
|
||||
results = append(results, model.ChunkWithScore{
|
||||
Content: chunk.Content,
|
||||
Score: score,
|
||||
SourceID: chunk.DocumentID,
|
||||
SourceName: sourceName,
|
||||
SourceType: "document",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
return results[i].Score > results[j].Score
|
||||
})
|
||||
|
||||
if len(results) > topK {
|
||||
results = results[:topK]
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *VectorService) Delete(ctx context.Context, sourceType, sourceID string) error {
|
||||
return s.repo.DeleteEmbeddingsBySource(sourceType, sourceID)
|
||||
}
|
||||
|
||||
func (s *VectorService) DeleteDocumentChunks(ctx context.Context, documentID string) error {
|
||||
_, err := s.repo.GetDocumentChunksByDocumentID(documentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cosineSimilarity(a, b []float64) float64 {
|
||||
if len(a) != len(b) {
|
||||
return 0
|
||||
}
|
||||
|
||||
var dotProduct, magA, magB float64
|
||||
for i := range a {
|
||||
dotProduct += a[i] * b[i]
|
||||
magA += a[i] * a[i]
|
||||
magB += b[i] * b[i]
|
||||
}
|
||||
|
||||
if magA == 0 || magB == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return dotProduct / (math.Sqrt(magA) * math.Sqrt(magB))
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package portfolio
|
||||
|
||||
import (
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/repository"
|
||||
)
|
||||
|
||||
type PortfolioService interface {
|
||||
GetPortfolioItems() ([]*model.PortfolioItem, error)
|
||||
GetPortfolioItemsWithPagination(page, pageSize int) ([]*model.PortfolioItem, int, error)
|
||||
GetPortfolioItemsByUserID(userID string) ([]*model.PortfolioItem, error)
|
||||
GetPortfolioItem(id uint) (*model.PortfolioItem, error)
|
||||
CreatePortfolioItem(item *model.PortfolioItem) error
|
||||
UpdatePortfolioItem(id uint, item *model.PortfolioItem) error
|
||||
DeletePortfolioItem(id uint) error
|
||||
}
|
||||
|
||||
type portfolioService struct {
|
||||
repo repository.ResumeRepository
|
||||
}
|
||||
|
||||
func NewPortfolioService(repo repository.ResumeRepository) PortfolioService {
|
||||
return &portfolioService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *portfolioService) GetPortfolioItems() ([]*model.PortfolioItem, error) {
|
||||
return s.repo.GetPortfolioItems()
|
||||
}
|
||||
|
||||
func (s *portfolioService) GetPortfolioItemsWithPagination(page, pageSize int) ([]*model.PortfolioItem, int, error) {
|
||||
return s.repo.GetPortfolioItemsWithPagination(page, pageSize)
|
||||
}
|
||||
|
||||
func (s *portfolioService) GetPortfolioItemsByUserID(userID string) ([]*model.PortfolioItem, error) {
|
||||
return s.repo.GetPortfolioItemsByUserID(userID)
|
||||
}
|
||||
|
||||
func (s *portfolioService) GetPortfolioItem(id uint) (*model.PortfolioItem, error) {
|
||||
return s.repo.GetPortfolioItem(id)
|
||||
}
|
||||
|
||||
func (s *portfolioService) CreatePortfolioItem(item *model.PortfolioItem) error {
|
||||
return s.repo.CreatePortfolioItem(item)
|
||||
}
|
||||
|
||||
func (s *portfolioService) UpdatePortfolioItem(id uint, item *model.PortfolioItem) error {
|
||||
return s.repo.UpdatePortfolioItem(id, item)
|
||||
}
|
||||
|
||||
func (s *portfolioService) DeletePortfolioItem(id uint) error {
|
||||
return s.repo.DeletePortfolioItem(id)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package quiz
|
||||
|
||||
import (
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type QuizService interface {
|
||||
CreateQuizRecord(record *model.QuizRecord) error
|
||||
GetQuizRecordsByResume(resumeRoute string) ([]*model.QuizRecord, error)
|
||||
GetQuizRecord(id string) (*model.QuizRecord, error)
|
||||
DeleteQuizRecord(id string) error
|
||||
CreateFavoriteQuestion(fav *model.FavoriteQuestion) error
|
||||
GetFavoriteQuestions(resumeRoute string, isFavorite, isCorrect *bool) ([]*model.FavoriteQuestion, error)
|
||||
DeleteFavoriteQuestion(id string) error
|
||||
ToggleFavorite(id string) error
|
||||
}
|
||||
|
||||
type quizService struct {
|
||||
repo repository.ResumeRepository
|
||||
}
|
||||
|
||||
func NewQuizService(repo repository.ResumeRepository) QuizService {
|
||||
return &quizService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *quizService) CreateQuizRecord(record *model.QuizRecord) error {
|
||||
if record.ID == "" {
|
||||
record.ID = uuid.New().String()
|
||||
}
|
||||
return s.repo.CreateQuizRecord(record)
|
||||
}
|
||||
|
||||
func (s *quizService) GetQuizRecordsByResume(resumeRoute string) ([]*model.QuizRecord, error) {
|
||||
return s.repo.GetQuizRecordsByResume(resumeRoute)
|
||||
}
|
||||
|
||||
func (s *quizService) GetQuizRecord(id string) (*model.QuizRecord, error) {
|
||||
return s.repo.GetQuizRecord(id)
|
||||
}
|
||||
|
||||
func (s *quizService) DeleteQuizRecord(id string) error {
|
||||
return s.repo.DeleteQuizRecord(id)
|
||||
}
|
||||
|
||||
func (s *quizService) CreateFavoriteQuestion(fav *model.FavoriteQuestion) error {
|
||||
if fav.ID == "" {
|
||||
fav.ID = uuid.New().String()
|
||||
}
|
||||
return s.repo.CreateFavoriteQuestion(fav)
|
||||
}
|
||||
|
||||
func (s *quizService) GetFavoriteQuestions(resumeRoute string, isFavorite, isCorrect *bool) ([]*model.FavoriteQuestion, error) {
|
||||
return s.repo.GetFavoriteQuestions(resumeRoute, isFavorite, isCorrect)
|
||||
}
|
||||
|
||||
func (s *quizService) DeleteFavoriteQuestion(id string) error {
|
||||
return s.repo.DeleteFavoriteQuestion(id)
|
||||
}
|
||||
|
||||
func (s *quizService) ToggleFavorite(id string) error {
|
||||
return s.repo.ToggleFavorite(id)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package resume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"resume-platform/internal/ai"
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/prompt"
|
||||
"resume-platform/pkg/logger"
|
||||
"resume-platform/pkg/utils"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ResumeGeneratorService struct {
|
||||
aiProvider ai.Provider
|
||||
}
|
||||
|
||||
func NewResumeGeneratorService(provider ai.Provider) *ResumeGeneratorService {
|
||||
return &ResumeGeneratorService{aiProvider: provider}
|
||||
}
|
||||
|
||||
func (s *ResumeGeneratorService) GenerateFromDocument(ctx context.Context, documentContent string) (*model.Resume, error) {
|
||||
if s.aiProvider == nil {
|
||||
return nil, fmt.Errorf("AI provider not configured")
|
||||
}
|
||||
|
||||
cleanedContent := cleanDocumentContent(documentContent)
|
||||
if cleanedContent == "" {
|
||||
return nil, fmt.Errorf("document content is empty after cleaning")
|
||||
}
|
||||
|
||||
maxContentLength := 15000
|
||||
if len(cleanedContent) > maxContentLength {
|
||||
cleanedContent = truncateContent(cleanedContent, maxContentLength)
|
||||
}
|
||||
|
||||
return s.extractAllSections(ctx, cleanedContent)
|
||||
}
|
||||
|
||||
func (s *ResumeGeneratorService) extractAllSections(ctx context.Context, content string) (*model.Resume, error) {
|
||||
promptText := prompt.BuildResumeExtractorPrompt(content)
|
||||
|
||||
result, err := s.aiProvider.Generate(ctx, promptText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cleanedResult := utils.CleanAIResponse(result)
|
||||
|
||||
var rawData struct {
|
||||
BasicInfo struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Location string `json:"location"`
|
||||
Website string `json:"website"`
|
||||
Summary string `json:"summary"`
|
||||
ExperienceYears interface{} `json:"experience_years"`
|
||||
JobTarget string `json:"job_target"`
|
||||
Industry string `json:"industry"`
|
||||
CoreAdvantages []interface{} `json:"core_advantages"`
|
||||
} `json:"basic_info"`
|
||||
Experience []struct {
|
||||
Company string `json:"company"`
|
||||
Position string `json:"position"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
Description string `json:"description"`
|
||||
Highlights []interface{} `json:"highlights"`
|
||||
Platforms []interface{} `json:"platforms"`
|
||||
} `json:"experience"`
|
||||
Education []struct {
|
||||
School string `json:"school"`
|
||||
Degree string `json:"degree"`
|
||||
Major string `json:"major"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
GPA string `json:"gpa"`
|
||||
} `json:"education"`
|
||||
Skills []struct {
|
||||
Name string `json:"name"`
|
||||
Level string `json:"level"`
|
||||
Category string `json:"category"`
|
||||
} `json:"skills"`
|
||||
Projects []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
TechStack []interface{} `json:"tech_stack"`
|
||||
URL string `json:"url"`
|
||||
Highlights []interface{} `json:"highlights"`
|
||||
Achievements []interface{} `json:"achievements"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
} `json:"projects"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(cleanedResult), &rawData); err != nil {
|
||||
logger.Errorf("Failed to parse AI result: %v, raw result: %s", err, cleanedResult)
|
||||
return nil, fmt.Errorf("failed to parse AI result: %w", err)
|
||||
}
|
||||
|
||||
coreAdvantages := make([]string, 0, len(rawData.BasicInfo.CoreAdvantages))
|
||||
for _, item := range rawData.BasicInfo.CoreAdvantages {
|
||||
coreAdvantages = append(coreAdvantages, utils.ConvertToString(item))
|
||||
}
|
||||
|
||||
experience := make([]model.Experience, 0, len(rawData.Experience))
|
||||
for _, exp := range rawData.Experience {
|
||||
if exp.Company == "" && exp.Position == "" {
|
||||
continue
|
||||
}
|
||||
highlights := make([]string, 0, len(exp.Highlights))
|
||||
for _, item := range exp.Highlights {
|
||||
highlights = append(highlights, utils.ConvertToString(item))
|
||||
}
|
||||
platforms := make([]string, 0, len(exp.Platforms))
|
||||
for _, item := range exp.Platforms {
|
||||
platforms = append(platforms, utils.ConvertToString(item))
|
||||
}
|
||||
experience = append(experience, model.Experience{
|
||||
Company: exp.Company,
|
||||
Position: exp.Position,
|
||||
StartDate: exp.StartDate,
|
||||
EndDate: exp.EndDate,
|
||||
Description: exp.Description,
|
||||
Highlights: highlights,
|
||||
Platforms: platforms,
|
||||
})
|
||||
}
|
||||
|
||||
education := make([]model.Education, 0, len(rawData.Education))
|
||||
for _, edu := range rawData.Education {
|
||||
if edu.School == "" {
|
||||
continue
|
||||
}
|
||||
education = append(education, model.Education{
|
||||
School: edu.School,
|
||||
Degree: edu.Degree,
|
||||
Major: edu.Major,
|
||||
StartDate: edu.StartDate,
|
||||
EndDate: edu.EndDate,
|
||||
GPA: edu.GPA,
|
||||
})
|
||||
}
|
||||
|
||||
skills := make([]model.Skill, 0, len(rawData.Skills))
|
||||
for _, skill := range rawData.Skills {
|
||||
if skill.Name == "" {
|
||||
continue
|
||||
}
|
||||
skills = append(skills, model.Skill{
|
||||
Name: skill.Name,
|
||||
Level: skill.Level,
|
||||
Category: skill.Category,
|
||||
})
|
||||
}
|
||||
|
||||
projects := make([]model.Project, 0, len(rawData.Projects))
|
||||
for _, proj := range rawData.Projects {
|
||||
if proj.Name == "" && proj.Description == "" {
|
||||
continue
|
||||
}
|
||||
techStack := make([]string, 0, len(proj.TechStack))
|
||||
for _, item := range proj.TechStack {
|
||||
techStack = append(techStack, utils.ConvertToString(item))
|
||||
}
|
||||
highlights := make([]string, 0, len(proj.Highlights))
|
||||
for _, item := range proj.Highlights {
|
||||
highlights = append(highlights, utils.ConvertToString(item))
|
||||
}
|
||||
achievements := make([]string, 0, len(proj.Achievements))
|
||||
for _, item := range proj.Achievements {
|
||||
achievements = append(achievements, utils.ConvertToString(item))
|
||||
}
|
||||
projects = append(projects, model.Project{
|
||||
Name: proj.Name,
|
||||
Description: proj.Description,
|
||||
TechStack: techStack,
|
||||
URL: proj.URL,
|
||||
Highlights: highlights,
|
||||
Achievements: achievements,
|
||||
StartDate: proj.StartDate,
|
||||
EndDate: proj.EndDate,
|
||||
ShowInResume: false,
|
||||
})
|
||||
}
|
||||
|
||||
resume := &model.Resume{
|
||||
ID: uuid.New().String(),
|
||||
Route: generateRouteFromName(rawData.BasicInfo.Name),
|
||||
BasicInfo: model.BasicInfo{
|
||||
Name: rawData.BasicInfo.Name,
|
||||
Title: rawData.BasicInfo.Title,
|
||||
Email: rawData.BasicInfo.Email,
|
||||
Phone: rawData.BasicInfo.Phone,
|
||||
Location: rawData.BasicInfo.Location,
|
||||
Website: rawData.BasicInfo.Website,
|
||||
Summary: rawData.BasicInfo.Summary,
|
||||
ExperienceYears: utils.ConvertToString(rawData.BasicInfo.ExperienceYears),
|
||||
JobTarget: rawData.BasicInfo.JobTarget,
|
||||
Industry: rawData.BasicInfo.Industry,
|
||||
CoreAdvantages: coreAdvantages,
|
||||
},
|
||||
Experience: experience,
|
||||
Education: education,
|
||||
Skills: skills,
|
||||
Projects: projects,
|
||||
Template: "modern",
|
||||
ShowInHome: false,
|
||||
}
|
||||
|
||||
return resume, nil
|
||||
}
|
||||
|
||||
func cleanDocumentContent(content string) string {
|
||||
return utils.CleanText(content)
|
||||
}
|
||||
|
||||
func generateRouteFromName(name string) string {
|
||||
if name == "" {
|
||||
return uuid.New().String()[:8]
|
||||
}
|
||||
route := ""
|
||||
for _, c := range name {
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') {
|
||||
route += string(c)
|
||||
}
|
||||
}
|
||||
if route == "" {
|
||||
return uuid.New().String()[:8]
|
||||
}
|
||||
return strings.ToLower(route)
|
||||
}
|
||||
|
||||
func truncateContent(content string, maxLength int) string {
|
||||
if len(content) <= maxLength {
|
||||
return content
|
||||
}
|
||||
|
||||
truncated := content[:maxLength]
|
||||
|
||||
for i := len(truncated) - 1; i >= 0; i-- {
|
||||
c := rune(truncated[i])
|
||||
if c == '。' || c == '!' || c == '?' || c == ';' ||
|
||||
c == '.' || c == '!' || c == '?' || c == ';' ||
|
||||
c == '\n' || c == '\r' {
|
||||
truncated = truncated[:i+1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return truncated + "\n\n[内容已截断,仅保留关键信息]"
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package resume
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ResumeService interface {
|
||||
GetResume(id string) (*model.Resume, error)
|
||||
GetResumeByRoute(route string) (*model.Resume, error)
|
||||
RouteExists(route string) bool
|
||||
GetAllResumes() ([]*model.Resume, error)
|
||||
GetResumesByUserID(userID string) ([]*model.Resume, error)
|
||||
GetResumesWithPagination(page, pageSize int) ([]*model.Resume, int, error)
|
||||
GetResumeWithMask(id string) (*model.Resume, error)
|
||||
CreateResume(resume *model.Resume) (*model.Resume, error)
|
||||
CreateResumeWithRoute(route string, resume *model.Resume) (*model.Resume, error)
|
||||
UpdateResume(id string, update *model.ResumeUpdate) (*model.Resume, error)
|
||||
DeleteResume(id string) error
|
||||
UpdateResumeUserID(resumeID, userID string) error
|
||||
InitDefaultResume() (*model.Resume, error)
|
||||
}
|
||||
|
||||
type resumeService struct {
|
||||
repo repository.ResumeRepository
|
||||
}
|
||||
|
||||
func NewResumeService(repo repository.ResumeRepository) ResumeService {
|
||||
return &resumeService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *resumeService) GetResume(id string) (*model.Resume, error) {
|
||||
return s.repo.GetResume(id)
|
||||
}
|
||||
|
||||
func (s *resumeService) GetResumeByRoute(route string) (*model.Resume, error) {
|
||||
return s.repo.GetResumeByRoute(route)
|
||||
}
|
||||
|
||||
func (s *resumeService) RouteExists(route string) bool {
|
||||
return s.repo.RouteExists(route)
|
||||
}
|
||||
|
||||
func (s *resumeService) GetAllResumes() ([]*model.Resume, error) {
|
||||
return s.repo.GetAllResumes()
|
||||
}
|
||||
|
||||
func (s *resumeService) GetResumesWithPagination(page, pageSize int) ([]*model.Resume, int, error) {
|
||||
return s.repo.GetResumesWithPagination(page, pageSize)
|
||||
}
|
||||
|
||||
func (s *resumeService) CreateResume(resume *model.Resume) (*model.Resume, error) {
|
||||
if resume.ID == "" {
|
||||
resume.ID = uuid.New().String()
|
||||
}
|
||||
if resume.Template == "" {
|
||||
resume.Template = "modern"
|
||||
}
|
||||
err := s.repo.CreateResume(resume)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.GetResume(resume.ID)
|
||||
}
|
||||
|
||||
func (s *resumeService) CreateResumeWithRoute(route string, resume *model.Resume) (*model.Resume, error) {
|
||||
if s.repo.RouteExists(route) {
|
||||
return nil, errors.New("route already exists")
|
||||
}
|
||||
|
||||
if resume.ID == "" {
|
||||
resume.ID = uuid.New().String()
|
||||
}
|
||||
if resume.Template == "" {
|
||||
resume.Template = "modern"
|
||||
}
|
||||
resume.Route = route
|
||||
|
||||
err := s.repo.CreateResume(resume)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.GetResume(resume.ID)
|
||||
}
|
||||
|
||||
func (s *resumeService) GetResumesByUserID(userID string) ([]*model.Resume, error) {
|
||||
return s.repo.GetResumesByUserID(userID)
|
||||
}
|
||||
|
||||
func (s *resumeService) UpdateResume(id string, update *model.ResumeUpdate) (*model.Resume, error) {
|
||||
if !s.repo.Exists(id) {
|
||||
return nil, errors.New("resume not found")
|
||||
}
|
||||
err := s.repo.UpdateResume(id, update)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.GetResume(id)
|
||||
}
|
||||
|
||||
func (s *resumeService) DeleteResume(id string) error {
|
||||
if !s.repo.Exists(id) {
|
||||
return errors.New("resume not found")
|
||||
}
|
||||
return s.repo.DeleteResume(id)
|
||||
}
|
||||
|
||||
func (s *resumeService) UpdateResumeUserID(resumeID, userID string) error {
|
||||
if !s.repo.Exists(resumeID) {
|
||||
return errors.New("resume not found")
|
||||
}
|
||||
return s.repo.UpdateResumeUserID(resumeID, userID)
|
||||
}
|
||||
|
||||
func (s *resumeService) InitDefaultResume() (*model.Resume, error) {
|
||||
defaultID := "default"
|
||||
if s.repo.Exists(defaultID) {
|
||||
return s.repo.GetResume(defaultID)
|
||||
}
|
||||
|
||||
defaultResume := &model.Resume{
|
||||
ID: defaultID,
|
||||
Route: defaultID,
|
||||
Template: "modern",
|
||||
BasicInfo: model.BasicInfo{
|
||||
Name: "张三",
|
||||
Title: "全栈开发工程师",
|
||||
Email: "zhangsan@example.com",
|
||||
Phone: "13800138000",
|
||||
Location: "北京市",
|
||||
Website: "https://zhangsan.dev",
|
||||
Summary: "5年以上软件开发经验,专注于Web全栈开发,熟悉Go、React、Vue等技术栈。热爱技术,善于解决复杂问题。",
|
||||
Avatar: "",
|
||||
},
|
||||
Experience: []model.Experience{
|
||||
{
|
||||
Company: "某知名互联网公司",
|
||||
Position: "高级开发工程师",
|
||||
StartDate: "2022-01",
|
||||
EndDate: "至今",
|
||||
Description: "负责核心业务系统的架构设计与开发",
|
||||
Highlights: []string{"主导微服务架构改造", "优化系统性能提升50%", "带领5人团队完成多个重要项目"},
|
||||
},
|
||||
{
|
||||
Company: "创业公司",
|
||||
Position: "技术负责人",
|
||||
StartDate: "2019-06",
|
||||
EndDate: "2021-12",
|
||||
Description: "从0到1搭建技术团队和产品",
|
||||
Highlights: []string{"建立完整技术体系", "打造百万级用户产品", "获得天使轮融资"},
|
||||
},
|
||||
},
|
||||
Education: []model.Education{
|
||||
{
|
||||
School: "清华大学",
|
||||
Degree: "本科",
|
||||
Major: "计算机科学与技术",
|
||||
StartDate: "2015-09",
|
||||
EndDate: "2019-06",
|
||||
GPA: "3.8/4.0",
|
||||
},
|
||||
},
|
||||
Skills: []model.Skill{
|
||||
{Name: "Go", Level: "expert", Category: "后端"},
|
||||
{Name: "Python", Level: "advanced", Category: "后端"},
|
||||
{Name: "React", Level: "advanced", Category: "前端"},
|
||||
{Name: "Vue", Level: "advanced", Category: "前端"},
|
||||
{Name: "MySQL", Level: "advanced", Category: "数据库"},
|
||||
{Name: "Redis", Level: "intermediate", Category: "数据库"},
|
||||
{Name: "Docker", Level: "advanced", Category: "运维"},
|
||||
{Name: "Kubernetes", Level: "intermediate", Category: "运维"},
|
||||
},
|
||||
Projects: []model.Project{
|
||||
{
|
||||
Name: "电商平台",
|
||||
Description: "基于微服务架构的B2C电商平台",
|
||||
TechStack: []string{"Go", "React", "MySQL", "Redis", "Kubernetes"},
|
||||
URL: "https://github.com/example/ecommerce",
|
||||
Highlights: []string{"日订单量突破10万", "系统可用性99.9%", "支持百万级并发"},
|
||||
},
|
||||
{
|
||||
Name: "任务管理系统",
|
||||
Description: "团队协作任务管理工具",
|
||||
TechStack: []string{"Vue", "Node.js", "MongoDB"},
|
||||
URL: "https://github.com/example/taskmanager",
|
||||
Highlights: []string{"500+企业客户", "月活跃用户10万"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := s.repo.CreateResume(defaultResume)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return defaultResume, nil
|
||||
}
|
||||
|
||||
func maskPhone(phone string) string {
|
||||
re := regexp.MustCompile(`(\d{3})\d{4}(\d{4})`)
|
||||
return re.ReplaceAllString(phone, "$1****$2")
|
||||
}
|
||||
|
||||
func maskEmail(email string) string {
|
||||
re := regexp.MustCompile(`(\w{1,3})[\w.]*@`)
|
||||
return re.ReplaceAllString(email, "$1***@")
|
||||
}
|
||||
|
||||
func (s *resumeService) GetResumeWithMask(id string) (*model.Resume, error) {
|
||||
resume, err := s.repo.GetResume(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resume.BasicInfo.Phone = maskPhone(resume.BasicInfo.Phone)
|
||||
resume.BasicInfo.Email = maskEmail(resume.BasicInfo.Email)
|
||||
|
||||
return resume, nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type SystemService interface {
|
||||
GetAllMenus() ([]*model.Menu, error)
|
||||
CreateMenu(menu *model.Menu) error
|
||||
UpdateMenu(id string, menu *model.Menu) error
|
||||
DeleteMenu(id string) error
|
||||
GetSystemConfig() (*model.SystemConfig, error)
|
||||
CreateSystemConfig(config *model.SystemConfig) error
|
||||
UpdateSystemConfig(id string, config *model.SystemConfig) error
|
||||
InitDefaultMenus() error
|
||||
}
|
||||
|
||||
type systemService struct {
|
||||
repo repository.ResumeRepository
|
||||
}
|
||||
|
||||
func NewSystemService(repo repository.ResumeRepository) SystemService {
|
||||
return &systemService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *systemService) GetAllMenus() ([]*model.Menu, error) {
|
||||
return s.repo.GetAllMenus()
|
||||
}
|
||||
|
||||
func (s *systemService) CreateMenu(menu *model.Menu) error {
|
||||
if menu.ID == "" {
|
||||
menu.ID = uuid.New().String()
|
||||
}
|
||||
return s.repo.CreateMenu(menu)
|
||||
}
|
||||
|
||||
func (s *systemService) UpdateMenu(id string, menu *model.Menu) error {
|
||||
return s.repo.UpdateMenu(id, menu)
|
||||
}
|
||||
|
||||
func (s *systemService) DeleteMenu(id string) error {
|
||||
return s.repo.DeleteMenu(id)
|
||||
}
|
||||
|
||||
func (s *systemService) GetSystemConfig() (*model.SystemConfig, error) {
|
||||
return s.repo.GetSystemConfig()
|
||||
}
|
||||
|
||||
func (s *systemService) CreateSystemConfig(config *model.SystemConfig) error {
|
||||
if config.ID == "" {
|
||||
config.ID = "default"
|
||||
}
|
||||
return s.repo.CreateSystemConfig(config)
|
||||
}
|
||||
|
||||
func (s *systemService) UpdateSystemConfig(id string, config *model.SystemConfig) error {
|
||||
return s.repo.UpdateSystemConfig(id, config)
|
||||
}
|
||||
|
||||
func (s *systemService) InitDefaultMenus() error {
|
||||
menus, err := s.repo.GetAllMenus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(menus) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
defaultMenus := []*model.Menu{
|
||||
{ID: "menu-user", Name: "人员管理", Icon: "fas fa-users", Path: "user", ParentID: "", SortOrder: 1, IsFixed: true, IsEnabled: true},
|
||||
{ID: "menu-menu", Name: "菜单管理", Icon: "fas fa-list", Path: "menu", ParentID: "", SortOrder: 2, IsFixed: true, IsEnabled: true},
|
||||
{ID: "menu-settings", Name: "系统设置", Icon: "fas fa-cog", Path: "settings", ParentID: "", SortOrder: 3, IsFixed: true, IsEnabled: true},
|
||||
}
|
||||
|
||||
for _, menu := range defaultMenus {
|
||||
if err := s.repo.CreateMenu(menu); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type UserService interface {
|
||||
CreateUser(user *model.User) error
|
||||
GetUserByUsername(username string) (*model.User, error)
|
||||
GetUserByID(id string) (*model.User, error)
|
||||
GetAllUsers() ([]*model.User, error)
|
||||
GetUsersWithPagination(page, pageSize int) ([]*model.User, int, error)
|
||||
UpdateUser(id string, user *model.User) error
|
||||
DeleteUser(id string) error
|
||||
}
|
||||
|
||||
type userService struct {
|
||||
repo repository.ResumeRepository
|
||||
}
|
||||
|
||||
func NewUserService(repo repository.ResumeRepository) UserService {
|
||||
return &userService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *userService) CreateUser(user *model.User) error {
|
||||
if user.ID == "" {
|
||||
user.ID = uuid.New().String()
|
||||
}
|
||||
return s.repo.CreateUser(user)
|
||||
}
|
||||
|
||||
func (s *userService) GetUserByUsername(username string) (*model.User, error) {
|
||||
return s.repo.GetUserByUsername(username)
|
||||
}
|
||||
|
||||
func (s *userService) GetUserByID(id string) (*model.User, error) {
|
||||
return s.repo.GetUserByID(id)
|
||||
}
|
||||
|
||||
func (s *userService) GetAllUsers() ([]*model.User, error) {
|
||||
return s.repo.GetAllUsers()
|
||||
}
|
||||
|
||||
func (s *userService) GetUsersWithPagination(page, pageSize int) ([]*model.User, int, error) {
|
||||
return s.repo.GetUsersWithPagination(page, pageSize)
|
||||
}
|
||||
|
||||
func (s *userService) UpdateUser(id string, user *model.User) error {
|
||||
return s.repo.UpdateUser(id, user)
|
||||
}
|
||||
|
||||
func (s *userService) DeleteUser(id string) error {
|
||||
return s.repo.DeleteUser(id)
|
||||
}
|
||||
Reference in New Issue
Block a user