initial public release

This commit is contained in:
2026-07-06 11:05:50 -04:00
commit ca518c5f8a
94 changed files with 15699 additions and 0 deletions
+15
View File
@@ -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
}
+147
View File
@@ -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
}
+141
View File
@@ -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
}
+153
View File
@@ -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)
}
}
+148
View File
@@ -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
}