initial public release
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
package docx
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Ingest parses a DOCX resume file and returns markdown text + writing style profile.
|
||||
// Returns error if file is not a valid DOCX or exceeds MaxDocxSize.
|
||||
func Ingest(filePath string) (markdown string, style StyleProfile, err error) {
|
||||
parser := NewDocumentParser(filePath)
|
||||
md, _, err := parser.ParseDOCX()
|
||||
if err != nil {
|
||||
return "", StyleProfile{}, fmt.Errorf("parse docx: %w", err)
|
||||
}
|
||||
extractor := NewStyleExtractor(md)
|
||||
return md, extractor.Extract(), nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package docx
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxDocxSize = 50 * 1024 * 1024 // 50MB
|
||||
)
|
||||
|
||||
// DocumentParser handles DOCX file parsing
|
||||
type DocumentParser struct {
|
||||
filePath string
|
||||
}
|
||||
|
||||
// NewDocumentParser creates a new DOCX parser
|
||||
func NewDocumentParser(filePath string) *DocumentParser {
|
||||
return &DocumentParser{filePath: filePath}
|
||||
}
|
||||
|
||||
// ParseDOCX extracts text from a DOCX file
|
||||
// Returns (markdown text, extracted images as bytes, error)
|
||||
func (p *DocumentParser) ParseDOCX() (string, map[string][]byte, error) {
|
||||
// Validate file size
|
||||
fileInfo, err := os.Stat(p.filePath)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to stat file: %w", err)
|
||||
}
|
||||
|
||||
if fileInfo.Size() > MaxDocxSize {
|
||||
return "", nil, fmt.Errorf("file exceeds 50MB limit: %d bytes", fileInfo.Size())
|
||||
}
|
||||
|
||||
// Validate ZIP magic number
|
||||
f, err := os.Open(p.filePath)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
magicBytes := make([]byte, 4)
|
||||
_, err = f.Read(magicBytes)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to read magic bytes: %w", err)
|
||||
}
|
||||
|
||||
if string(magicBytes) != "PK\x03\x04" {
|
||||
return "", nil, fmt.Errorf("not a valid DOCX file (invalid ZIP header)")
|
||||
}
|
||||
|
||||
// Open as ZIP
|
||||
f.Seek(0, 0)
|
||||
reader, err := zip.NewReader(f, fileInfo.Size())
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to open ZIP: %w", err)
|
||||
}
|
||||
|
||||
// Extract document.xml
|
||||
var docText string
|
||||
images := make(map[string][]byte)
|
||||
|
||||
for _, file := range reader.File {
|
||||
if file.Name == "word/document.xml" {
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to open document.xml: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
bytes, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to read document.xml: %w", err)
|
||||
}
|
||||
|
||||
docText, err = p.extractTextFromXML(bytes)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to extract text: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract images from media/
|
||||
if strings.HasPrefix(file.Name, "word/media/") {
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
imageBytes, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
fileName := filepath.Base(file.Name)
|
||||
images[fileName] = imageBytes
|
||||
}
|
||||
}
|
||||
|
||||
return docText, images, nil
|
||||
}
|
||||
|
||||
// extractTextFromXML parses word/document.xml and extracts plain text
|
||||
// Unsafe XML entity resolution is disabled by default in Go's xml.Decoder
|
||||
func (p *DocumentParser) extractTextFromXML(data []byte) (string, error) {
|
||||
var doc struct {
|
||||
XMLName xml.Name
|
||||
Body struct {
|
||||
Paragraphs []struct {
|
||||
Text string `xml:"w|t"`
|
||||
Runs []struct {
|
||||
Text string `xml:"w|t"`
|
||||
} `xml:"w|r"`
|
||||
} `xml:"w|p"`
|
||||
} `xml:"w|body"`
|
||||
}
|
||||
|
||||
decoder := xml.NewDecoder(strings.NewReader(string(data)))
|
||||
// XXE protection: Go's xml.Decoder is safe by default (no entity resolution)
|
||||
decoder.Strict = false
|
||||
|
||||
err := decoder.Decode(&doc)
|
||||
if err != nil && err != io.EOF {
|
||||
return "", fmt.Errorf("failed to decode XML: %w", err)
|
||||
}
|
||||
|
||||
// Extract text from paragraphs
|
||||
var result []string
|
||||
for _, p := range doc.Body.Paragraphs {
|
||||
var text []string
|
||||
for _, run := range p.Runs {
|
||||
if run.Text != "" {
|
||||
text = append(text, run.Text)
|
||||
}
|
||||
}
|
||||
if len(text) > 0 {
|
||||
result = append(result, strings.Join(text, ""))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(result, "\n"), nil
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package docx
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StyleProfile represents the writing style characteristics of a resume.
|
||||
type StyleProfile struct {
|
||||
ActionVerbs []string // top 20 most frequent action verbs from bullet starts
|
||||
SummaryStyle string // first 2 sentences from any "Summary" or "Profile" section
|
||||
BulletPatterns []string // first 5 words of each bullet (up to 30 bullets)
|
||||
}
|
||||
|
||||
// StyleExtractor analyzes parsed resume markdown text.
|
||||
type StyleExtractor struct {
|
||||
markdown string
|
||||
}
|
||||
|
||||
// NewStyleExtractor creates a new style extractor for the given markdown.
|
||||
func NewStyleExtractor(markdown string) *StyleExtractor {
|
||||
return &StyleExtractor{markdown: markdown}
|
||||
}
|
||||
|
||||
// Extract analyzes the markdown and returns a StyleProfile.
|
||||
func (s *StyleExtractor) Extract() StyleProfile {
|
||||
return StyleProfile{
|
||||
ActionVerbs: s.extractActionVerbs(),
|
||||
SummaryStyle: s.extractSummary(),
|
||||
BulletPatterns: s.extractBulletPatterns(),
|
||||
}
|
||||
}
|
||||
|
||||
// extractActionVerbs finds lines starting with bullet points, extracts the first word
|
||||
// (action verb), deduplicates, and returns the top 20 by frequency.
|
||||
func (s *StyleExtractor) extractActionVerbs() []string {
|
||||
lines := strings.Split(s.markdown, "\n")
|
||||
verbCount := make(map[string]int)
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
// Match lines starting with "- " or "* "
|
||||
if strings.HasPrefix(line, "- ") {
|
||||
line = strings.TrimPrefix(line, "- ")
|
||||
} else if strings.HasPrefix(line, "* ") {
|
||||
line = strings.TrimPrefix(line, "* ")
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract first word
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) > 0 {
|
||||
verb := strings.ToLower(fields[0])
|
||||
verbCount[verb]++
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by frequency (descending) and then alphabetically for stability
|
||||
type verbFreq struct {
|
||||
verb string
|
||||
count int
|
||||
}
|
||||
var sorted []verbFreq
|
||||
for verb, count := range verbCount {
|
||||
sorted = append(sorted, verbFreq{verb, count})
|
||||
}
|
||||
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
if sorted[i].count != sorted[j].count {
|
||||
return sorted[i].count > sorted[j].count
|
||||
}
|
||||
return sorted[i].verb < sorted[j].verb
|
||||
})
|
||||
|
||||
var result []string
|
||||
for i, vf := range sorted {
|
||||
if i >= 20 {
|
||||
break
|
||||
}
|
||||
result = append(result, vf.verb)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// extractSummary finds a heading matching Summary|Profile|About|Objective and
|
||||
// returns the next 2 non-empty lines.
|
||||
func (s *StyleExtractor) extractSummary() string {
|
||||
lines := strings.Split(s.markdown, "\n")
|
||||
headingRegex := regexp.MustCompile(`(?i)^\s*#+\s+(summary|profile|about|objective)\s*$`)
|
||||
|
||||
for i, line := range lines {
|
||||
if headingRegex.MatchString(line) {
|
||||
// Collect next 2 non-empty lines
|
||||
var summaryLines []string
|
||||
for j := i + 1; j < len(lines) && len(summaryLines) < 2; j++ {
|
||||
trimmed := strings.TrimSpace(lines[j])
|
||||
if trimmed != "" && !strings.HasPrefix(trimmed, "#") {
|
||||
summaryLines = append(summaryLines, trimmed)
|
||||
}
|
||||
}
|
||||
if len(summaryLines) > 0 {
|
||||
return strings.Join(summaryLines, " ")
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractBulletPatterns extracts the first 5 words of each bullet line (up to 30 bullets).
|
||||
func (s *StyleExtractor) extractBulletPatterns() []string {
|
||||
lines := strings.Split(s.markdown, "\n")
|
||||
var patterns []string
|
||||
|
||||
for _, line := range lines {
|
||||
if len(patterns) >= 30 {
|
||||
break
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
// Match lines starting with "- " or "* "
|
||||
if strings.HasPrefix(line, "- ") {
|
||||
line = strings.TrimPrefix(line, "- ")
|
||||
} else if strings.HasPrefix(line, "* ") {
|
||||
line = strings.TrimPrefix(line, "* ")
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract first 5 words
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) > 5 {
|
||||
fields = fields[:5]
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
pattern := strings.Join(fields, " ")
|
||||
patterns = append(patterns, pattern)
|
||||
}
|
||||
}
|
||||
return patterns
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package docx
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStyleExtractor_Extract(t *testing.T) {
|
||||
fixture := `# Professional Summary
|
||||
|
||||
## Summary
|
||||
Experienced software engineer with 10 years of expertise in cloud infrastructure and backend systems.
|
||||
Passionate about building scalable, maintainable solutions that drive business value.
|
||||
|
||||
## Experience
|
||||
|
||||
### Senior Engineer at TechCorp (2020-2024)
|
||||
- Designed microservices architecture handling 1M+ requests/day
|
||||
- Implemented CI/CD pipeline reducing deployment time by 60%
|
||||
- Led team of 5 engineers on critical infrastructure project
|
||||
- Optimized database queries improving response time by 40%
|
||||
- Mentored junior developers and conducted code reviews
|
||||
|
||||
### Engineer at StartupXYZ (2018-2020)
|
||||
- Built REST API from scratch using Go and PostgreSQL
|
||||
- Deployed applications to Kubernetes cluster
|
||||
- Fixed critical production bugs within SLA requirements
|
||||
- Developed monitoring dashboard for system observability
|
||||
- Contributed to open source projects during hackathons
|
||||
|
||||
## Skills
|
||||
- Languages: Go, Python, JavaScript
|
||||
- Tools: Kubernetes, Docker, PostgreSQL
|
||||
`
|
||||
|
||||
extractor := NewStyleExtractor(fixture)
|
||||
profile := extractor.Extract()
|
||||
|
||||
// Test action verbs: should extract and deduplicate
|
||||
if len(profile.ActionVerbs) == 0 {
|
||||
t.Errorf("Expected action verbs, got empty list")
|
||||
}
|
||||
if len(profile.ActionVerbs) > 20 {
|
||||
t.Errorf("Expected max 20 action verbs, got %d", len(profile.ActionVerbs))
|
||||
}
|
||||
// Check some common verbs are present
|
||||
hasDesigned := false
|
||||
hasImplemented := false
|
||||
for _, v := range profile.ActionVerbs {
|
||||
if v == "designed" {
|
||||
hasDesigned = true
|
||||
}
|
||||
if v == "implemented" {
|
||||
hasImplemented = true
|
||||
}
|
||||
}
|
||||
if !hasDesigned || !hasImplemented {
|
||||
t.Errorf("Expected 'designed' and 'implemented' in action verbs, got %v", profile.ActionVerbs)
|
||||
}
|
||||
|
||||
// Test summary: should find Summary section and extract 2 sentences
|
||||
if profile.SummaryStyle == "" {
|
||||
t.Errorf("Expected summary style, got empty string")
|
||||
}
|
||||
if !strings.Contains(profile.SummaryStyle, "software engineer") {
|
||||
t.Errorf("Expected 'software engineer' in summary, got %q", profile.SummaryStyle)
|
||||
}
|
||||
if !strings.Contains(profile.SummaryStyle, "scalable") {
|
||||
t.Errorf("Expected 'scalable' in summary, got %q", profile.SummaryStyle)
|
||||
}
|
||||
|
||||
// Test bullet patterns: should extract first 5 words of bullets (up to 30)
|
||||
if len(profile.BulletPatterns) == 0 {
|
||||
t.Errorf("Expected bullet patterns, got empty list")
|
||||
}
|
||||
if len(profile.BulletPatterns) > 30 {
|
||||
t.Errorf("Expected max 30 bullet patterns, got %d", len(profile.BulletPatterns))
|
||||
}
|
||||
// Each pattern should have at most 5 words
|
||||
for i, pattern := range profile.BulletPatterns {
|
||||
words := strings.Fields(pattern)
|
||||
if len(words) > 5 {
|
||||
t.Errorf("Pattern %d has %d words (max 5), pattern: %q", i, len(words), pattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStyleExtractor_ActionVerbDedupe(t *testing.T) {
|
||||
fixture := `## Experience
|
||||
- Built system architecture
|
||||
- Built API endpoints
|
||||
- Built deployment pipeline
|
||||
- Designed database schema
|
||||
- Implemented feature X
|
||||
`
|
||||
|
||||
extractor := NewStyleExtractor(fixture)
|
||||
profile := extractor.Extract()
|
||||
|
||||
// Verify that 'built' appears only once in actionVerbs (deduped)
|
||||
builtCount := 0
|
||||
for _, v := range profile.ActionVerbs {
|
||||
if v == "built" {
|
||||
builtCount++
|
||||
}
|
||||
}
|
||||
if builtCount != 1 {
|
||||
t.Errorf("Expected 'built' to appear once after deduplication, got %d times", builtCount)
|
||||
}
|
||||
|
||||
// 'built' should be first (highest frequency)
|
||||
if len(profile.ActionVerbs) > 0 && profile.ActionVerbs[0] != "built" {
|
||||
t.Errorf("Expected 'built' to be first (highest freq), got %v", profile.ActionVerbs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStyleExtractor_NoSummary(t *testing.T) {
|
||||
fixture := `## Experience
|
||||
- Did something
|
||||
- Did another thing
|
||||
`
|
||||
|
||||
extractor := NewStyleExtractor(fixture)
|
||||
profile := extractor.Extract()
|
||||
|
||||
if profile.SummaryStyle != "" {
|
||||
t.Errorf("Expected empty summary when no Summary section found, got %q", profile.SummaryStyle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStyleExtractor_BulletLimitedWords(t *testing.T) {
|
||||
fixture := `## Experience
|
||||
- First second third fourth fifth sixth seventh eighth
|
||||
- Another pattern with lots and lots of words
|
||||
`
|
||||
|
||||
extractor := NewStyleExtractor(fixture)
|
||||
profile := extractor.Extract()
|
||||
|
||||
if len(profile.BulletPatterns) < 2 {
|
||||
t.Fatalf("Expected at least 2 bullet patterns, got %d", len(profile.BulletPatterns))
|
||||
}
|
||||
|
||||
// First pattern should have only 5 words
|
||||
pattern := profile.BulletPatterns[0]
|
||||
words := strings.Fields(pattern)
|
||||
if len(words) != 5 {
|
||||
t.Errorf("Expected 5 words in first pattern, got %d: %q", len(words), pattern)
|
||||
}
|
||||
if !strings.HasPrefix(pattern, "First second third fourth fifth") {
|
||||
t.Errorf("Expected pattern to start with 'First second third fourth fifth', got %q", pattern)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package docx
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// VoiceExtractor handles extracting and transcribing audio from DOCX
|
||||
type VoiceExtractor struct {
|
||||
filePath string
|
||||
}
|
||||
|
||||
// NewVoiceExtractor creates a new voice extractor
|
||||
func NewVoiceExtractor(filePath string) *VoiceExtractor {
|
||||
return &VoiceExtractor{filePath: filePath}
|
||||
}
|
||||
|
||||
// ExtractAudioFiles finds and extracts audio files from DOCX
|
||||
func (v *VoiceExtractor) ExtractAudioFiles() (map[string][]byte, error) {
|
||||
audioFormats := map[string]bool{
|
||||
".m4a": true,
|
||||
".mp3": true,
|
||||
".wav": true,
|
||||
".ogg": true,
|
||||
".aac": true,
|
||||
".flac": true,
|
||||
}
|
||||
|
||||
f, err := os.Open(v.filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fileInfo, _ := f.Stat()
|
||||
reader, err := zip.NewReader(f, fileInfo.Size())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open ZIP: %w", err)
|
||||
}
|
||||
|
||||
audioFiles := make(map[string][]byte)
|
||||
|
||||
for _, file := range reader.File {
|
||||
// Check for audio files in media/ or anywhere in docProps
|
||||
if strings.Contains(strings.ToLower(file.Name), "media") || strings.Contains(strings.ToLower(file.Name), "docprops") {
|
||||
ext := strings.ToLower(filepath.Ext(file.Name))
|
||||
if audioFormats[ext] {
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
data, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
fileName := filepath.Base(file.Name)
|
||||
audioFiles[fileName] = data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return audioFiles, nil
|
||||
}
|
||||
|
||||
// TranscribeAudio uses Claude CLI to transcribe audio
|
||||
func (v *VoiceExtractor) TranscribeAudio(audioBytes []byte, filename string) (string, error) {
|
||||
// Write audio to temp file
|
||||
tmpFile, err := os.CreateTemp("", "audio-*."+strings.TrimPrefix(filepath.Ext(filename), "."))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
if _, err := tmpFile.Write(audioBytes); err != nil {
|
||||
tmpFile.Close()
|
||||
return "", fmt.Errorf("failed to write audio: %w", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
// Call claude CLI to transcribe
|
||||
// Note: This assumes claude binary is in PATH
|
||||
cmd := exec.Command("claude", "-p", fmt.Sprintf("Transcribe this audio file: %s", tmpFile.Name()))
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to transcribe: %v (stderr: %s)", err, stderr.String())
|
||||
}
|
||||
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
// VoiceSample represents extracted voice + transcription
|
||||
type VoiceSample struct {
|
||||
Filename string `json:"filename"`
|
||||
Content []byte `json:"content,omitempty"` // only for storage
|
||||
Transcript string `json:"transcript"`
|
||||
}
|
||||
|
||||
// ExtractAndTranscribe extracts audio from DOCX and transcribes it
|
||||
func (v *VoiceExtractor) ExtractAndTranscribe() ([]VoiceSample, error) {
|
||||
audioFiles, err := v.ExtractAudioFiles()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var samples []VoiceSample
|
||||
for filename, content := range audioFiles {
|
||||
transcript, err := v.TranscribeAudio(content, filename)
|
||||
if err != nil {
|
||||
// Log but continue — transcription failure shouldn't block intake
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to transcribe %s: %v\n", filename, err)
|
||||
transcript = "[transcription failed]"
|
||||
}
|
||||
|
||||
samples = append(samples, VoiceSample{
|
||||
Filename: filename,
|
||||
Content: content,
|
||||
Transcript: transcript,
|
||||
})
|
||||
}
|
||||
|
||||
return samples, nil
|
||||
}
|
||||
|
||||
// StorageFormat returns the sample as JSON for SQLite storage
|
||||
func (vs *VoiceSample) JSON() (string, error) {
|
||||
// Don't store the raw audio bytes in DB — only filename + transcript
|
||||
storage := map[string]interface{}{
|
||||
"filename": vs.Filename,
|
||||
"transcript": vs.Transcript,
|
||||
}
|
||||
b, err := json.Marshal(storage)
|
||||
return string(b), err
|
||||
}
|
||||
Reference in New Issue
Block a user