Skip to content

Commit bd2ad34

Browse files
committed
feat: add ConfluenceFAQSplitter and TeamsQnALoader skills
- ConfluenceFAQSplitter: extracts Q&A pairs from Confluence .docx files - TeamsQnALoader: ingests enriched Q&A JSON from FAQ pipeline - Update indexer-skills.md with new skills and use cases
1 parent 28ba943 commit bd2ad34

6 files changed

Lines changed: 658 additions & 2 deletions

File tree

docs/readme/indexer-skills.md

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,19 @@ This document describes all available skills that can be used in the indexer pip
2727
3. An `embedding` to generate embeddings from the chunks.
2828
4. A `vector-store` to store the embeddings.
2929

30+
4. You have FAQ documents exported from Confluence (`.docx` files) and want to extract Q&A pairs for vectorization? You'll typically need:
31+
32+
1. An `exporter` (Scroll Word) or `file-scanner` to get the `.docx` files.
33+
2. A `confluence-faq-splitter` to extract Q&A pairs directly from the `.docx` headings.
34+
3. An `embedding` to generate embeddings from the Q&A chunks.
35+
4. A `vector-store` to store the embeddings.
36+
37+
5. You have enriched Q&A JSON output from a Teams FAQ pipeline and want to index it? You'll typically need:
38+
39+
1. A `teams-qna-loader` to load the enriched Q&A pairs from the JSON file.
40+
2. An `embedding` to generate embeddings from the Q&A content.
41+
3. A `vector-store` to store the embeddings.
42+
3043

3144
# Available Skills
3245

@@ -103,7 +116,7 @@ Supported file extensions:
103116
</details>
104117
105118
<details><summary>Web loaders</summary>
106-
Load data from web.
119+
Load data from web or structured files.
107120
108121
### Jira Loader
109122
Loads data from Jira issues
@@ -119,6 +132,18 @@ Loads data from Jira issues
119132
- JSTAD-XYZ
120133
- JIRA-1234
121134
```
135+
136+
### Teams Q&A Loader
137+
Loads enriched Q&A pairs from a JSON file produced by the FAQ enrichment pipeline. Each Q&A pair becomes a single document with one chunk. The skill prefers rephrased questions/answers when available, falling back to originals.
138+
139+
```yaml
140+
- skill: &TeamsQnALoader
141+
type: loader
142+
name: teams-qna-loader
143+
params:
144+
file_path: data/processed_output/enriched_qna.json # Required: path to enriched Q&A JSON file
145+
tag: teams-faq # Optional: tag for chunks (default: "enriched-qna")
146+
```
122147
</details>
123148
124149
@@ -151,6 +176,29 @@ Splits text by grouping semantically equivalent chunks together. A bit more adva
151176
api_version: your-api-version
152177
deployment_name: your-deployment-name
153178
```
179+
180+
### Confluence FAQ Splitter
181+
Extracts Q&A pairs directly from FAQ `.docx` files exported from Confluence. Each heading that contains a `?` or starts with a problem/question pattern (e.g. "How do I", "I cannot") is treated as a question, and the body content below it becomes the answer. Each Q&A pair is produced as a single atomic chunk. No `file-reader` is needed — this skill reads `.docx` files directly via `python-docx`.
182+
183+
All parameters are optional with sensible defaults.
184+
185+
```yaml
186+
- skill: &ConfluenceFAQSplitter
187+
type: splitter
188+
name: confluence-faq-splitter
189+
params:
190+
min_heading_level: 2 # Minimum heading level for questions (default: 2)
191+
max_heading_level: 6 # Maximum heading level for questions (default: 6)
192+
skip_headings: # Heading titles to skip (default: ['summary'])
193+
- summary
194+
skip_patterns: # Text patterns to skip in answer content (default: ['CONFIDENTIAL', 'Search the FAQ', 'Search Artifactory FAQ'])
195+
- CONFIDENTIAL
196+
question_patterns: # Prefixes that indicate a question (default: ['i am ', 'i cannot ', 'how do i ', 'what is ', ...])
197+
- "how do i "
198+
- "i cannot "
199+
stop_sections: # Regex patterns for sections that end Q&A extraction (default: ['related articles', 'see also'])
200+
- "^\\s*related\\s*articles?\\s*$"
201+
```
154202
</details>
155203

156204
<details><summary>Embedding</summary>

src/docs2vecs/subcommands/indexer/config/config_schema.yaml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,37 @@ definitions:
105105
type: integer
106106
required: False
107107
min: 0
108+
# ConfluenceFAQSplitter params
109+
min_heading_level:
110+
type: integer
111+
required: False
112+
min: 1
113+
max: 9
114+
max_heading_level:
115+
type: integer
116+
required: False
117+
min: 1
118+
max: 9
119+
skip_patterns:
120+
type: list
121+
required: False
122+
schema:
123+
type: string
124+
skip_headings:
125+
type: list
126+
required: False
127+
schema:
128+
type: string
129+
question_patterns:
130+
type: list
131+
required: False
132+
schema:
133+
type: string
134+
stop_sections:
135+
type: list
136+
required: False
137+
schema:
138+
type: string
108139
mode:
109140
type: string
110141
required: False
@@ -162,6 +193,9 @@ definitions:
162193
path:
163194
type: string
164195
required: False
196+
file_path:
197+
type: string
198+
required: False
165199
embedding_model:
166200
type: dict
167201
schema:
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import json
2+
from pathlib import Path
3+
from typing import List
4+
from typing import Optional
5+
6+
from docs2vecs.subcommands.indexer.config.config import Config
7+
from docs2vecs.subcommands.indexer.document import Chunk
8+
from docs2vecs.subcommands.indexer.document.document import Document
9+
from docs2vecs.subcommands.indexer.skills.skill import IndexerSkill
10+
11+
12+
class TeamsQnALoaderSkill(IndexerSkill):
13+
"""A skill that loads enriched Q&A pairs from the FAQ pipeline JSON output.
14+
15+
The JSON file should be an array of enriched Q&A objects with:
16+
- thread_id: Unique identifier for the conversation thread
17+
- question: Original question text
18+
- rephrased_question: AI-polished question (used for embedding)
19+
- rephrased_answer: AI-summarized answer (used as content)
20+
- topic: Clustered topic category
21+
- key_phrases: Extracted key phrases
22+
- question_sender: Original question author
23+
- timestamp: Message timestamp
24+
- answers: Array of original answers
25+
26+
Configuration parameters:
27+
- file_path (str): Path to the enriched Q&A JSON file
28+
"""
29+
30+
def __init__(self, skill_config: dict, global_config: Config) -> None:
31+
super().__init__(skill_config, global_config)
32+
self._file_path = Path(self._config["file_path"]).expanduser().resolve()
33+
self.tag = self._config.get("tag", "enriched-qna")
34+
35+
def run(self, documents: Optional[List[Document]]) -> List[Document]:
36+
"""Load enriched Q&A pairs from JSON file and create Document objects with chunks.
37+
38+
Args:
39+
documents: Not used by this skill (loader skill)
40+
41+
Returns:
42+
List of Documents with chunks populated from enriched Q&A JSON
43+
"""
44+
self.logger.info(f"Running TeamsQnALoaderSkill on {self._file_path}...")
45+
46+
if not self._file_path.exists():
47+
raise FileNotFoundError(f"Enriched Q&A JSON file not found: {self._file_path}")
48+
49+
# Load JSON file
50+
with self._file_path.open('r', encoding='utf-8') as f:
51+
qna_list = json.load(f)
52+
53+
if not qna_list:
54+
self.logger.warning(f"No Q&A pairs found in JSON file: {self._file_path}")
55+
return []
56+
57+
if not isinstance(qna_list, list):
58+
raise ValueError(f"Expected JSON array of Q&A objects, got {type(qna_list).__name__}")
59+
60+
result = []
61+
62+
# Process each enriched Q&A pair
63+
for idx, qna in enumerate(qna_list):
64+
# Extract rephrased question and answer, falling back to originals
65+
question = qna.get("rephrased_question") or qna.get("question", "")
66+
answer = qna.get("rephrased_answer") or self._get_best_answer(qna)
67+
68+
# Skip if no meaningful content
69+
if not question.strip() or not answer.strip():
70+
self.logger.debug(f"Skipping Q&A pair {idx} - missing question or answer")
71+
continue
72+
73+
# Build content with both question and answer
74+
topic = qna.get("topic", "General")
75+
content = f"Q: {question}\n\nA: {answer}"
76+
77+
# Generate document ID from thread_id or index
78+
thread_id = qna.get("thread_id") or f"qna_{idx}"
79+
document_id = self._sanitize_id(thread_id)
80+
81+
# Use source_link from the Q&A pair (Teams message deep link) if available
82+
source_url = qna.get("source_link", "").strip()
83+
84+
# Create a Document object
85+
doc = Document(filename=str(self._file_path))
86+
87+
# Create a Chunk object from the Q&A pair
88+
chunk = Chunk()
89+
chunk.document_id = document_id
90+
chunk.document_name = f"{topic} - FAQ"
91+
chunk.tag = self.tag
92+
chunk.content = content
93+
chunk.chunk_id = f"{document_id}_chunk_0"
94+
chunk.source_link = source_url
95+
96+
# Add chunk to document
97+
doc.add_chunk(chunk)
98+
result.append(doc)
99+
100+
self.logger.debug(f"Loaded Q&A: {document_id} | Topic: {topic}")
101+
102+
self.logger.info(f"Successfully loaded {len(result)} enriched Q&A pairs from JSON")
103+
104+
return result
105+
106+
def _get_best_answer(self, qna: dict) -> str:
107+
"""Get the best answer from the answers array, preferring expert answers."""
108+
answers = qna.get("answers", [])
109+
if not answers:
110+
return ""
111+
112+
# Prefer expert answers
113+
expert_answers = [a for a in answers if a.get("is_expert", False)]
114+
if expert_answers:
115+
return expert_answers[0].get("answer", "")
116+
117+
# Fall back to first answer
118+
return answers[0].get("answer", "")
119+
120+
def _sanitize_id(self, thread_id: str) -> str:
121+
"""Sanitize thread_id to be a valid document ID."""
122+
# Remove any characters that might cause issues in Azure Search
123+
import re
124+
sanitized = re.sub(r'[^a-zA-Z0-9_-]', '_', str(thread_id))
125+
return sanitized[:128] # Limit length

src/docs2vecs/subcommands/indexer/skills/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from .llama_fastembed_embedding_skill import LlamaFastembedEmbeddingSkill
1414
from .local_document_parser import LocalDocumentParser
1515
from .faiss_vector_store_skill import FaissVectorStoreSkill
16+
from .teams_qna_loader_skill import TeamsQnALoaderSkill
17+
from .confluence_faq_splitter_skill import ConfluenceFAQSplitter
1618

1719

1820
__all__ = [
@@ -31,4 +33,6 @@
3133
"LlamaFastembedEmbeddingSkill",
3234
"LocalDocumentParser",
3335
"FaissVectorStoreSkill",
36+
"TeamsQnALoaderSkill",
37+
"ConfluenceFAQSplitter",
3438
]

0 commit comments

Comments
 (0)