raft-ft 2.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- raft/__init__.py +9 -0
- raft/chunker.py +138 -0
- raft/cli.py +189 -0
- raft/convo_structurer.py +338 -0
- raft/embeddings_helpers.py +106 -0
- raft/files_helper.py +179 -0
- raft/flows.py +116 -0
- raft/generate_finetune.py +83 -0
- raft/hf_finetune.py +268 -0
- raft/hx.py +169 -0
- raft/interactive.py +42 -0
- raft/memories.py +212 -0
- raft/oai_finetune.py +199 -0
- raft/prompt_manager.py +122 -0
- raft/substack_embeddings.py +69 -0
- raft/tweet_mode.py +320 -0
- raft_ft-2.2.0.dist-info/METADATA +215 -0
- raft_ft-2.2.0.dist-info/RECORD +21 -0
- raft_ft-2.2.0.dist-info/WHEEL +4 -0
- raft_ft-2.2.0.dist-info/entry_points.txt +2 -0
- raft_ft-2.2.0.dist-info/licenses/LICENSE +22 -0
raft/__init__.py
ADDED
raft/chunker.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module contains the chunker function, which splits blog posts into chunks
|
|
3
|
+
of a maximum length.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from typing import List, Dict, Tuple, Generator
|
|
8
|
+
import tiktoken
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
MAX_EMBEDDING_LENGTH = 2048
|
|
12
|
+
encoding = tiktoken.encoding_for_model("gpt-3.5-turbo")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def map_line(line: str) -> Tuple[str, str, int]:
|
|
16
|
+
"""
|
|
17
|
+
Encode and potentially truncate a line of text.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
line (str): The input line of text.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Tuple[str, str, int]: A tuple containing:
|
|
24
|
+
- the original or truncated line,
|
|
25
|
+
- the decoded version, and
|
|
26
|
+
- the length of the encoded line.
|
|
27
|
+
"""
|
|
28
|
+
encoded = encoding.encode(line)
|
|
29
|
+
if len(encoded) < MAX_EMBEDDING_LENGTH:
|
|
30
|
+
return (line, encoding.decode(encoded), len(encoded))
|
|
31
|
+
else:
|
|
32
|
+
truncated_encoded = encoded[:MAX_EMBEDDING_LENGTH]
|
|
33
|
+
return (
|
|
34
|
+
line[:MAX_EMBEDDING_LENGTH],
|
|
35
|
+
encoding.decode(truncated_encoded),
|
|
36
|
+
MAX_EMBEDDING_LENGTH,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def split_into_chunks(
|
|
41
|
+
blog_posts: pd.DataFrame,
|
|
42
|
+
) -> Generator[Tuple[Dict[str, str], str], None, None]:
|
|
43
|
+
"""
|
|
44
|
+
Split blog posts into chunks of a maximum length.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
blog_posts (pd.DataFrame): DataFrame containing blog post data.
|
|
48
|
+
|
|
49
|
+
Yields:
|
|
50
|
+
Tuple[Dict[str, str], str]: A tuple containing metadata
|
|
51
|
+
and the chunked content.
|
|
52
|
+
"""
|
|
53
|
+
for _, post in blog_posts.iterrows():
|
|
54
|
+
print(f"Splitting {post['title']}")
|
|
55
|
+
lines = list(map(map_line, post["content"].split("\n")))
|
|
56
|
+
|
|
57
|
+
# Calculate total parts
|
|
58
|
+
total_parts = 0
|
|
59
|
+
temp_chunk_length = 0
|
|
60
|
+
for _, _, length in lines:
|
|
61
|
+
if temp_chunk_length + length > MAX_EMBEDDING_LENGTH:
|
|
62
|
+
total_parts += 1
|
|
63
|
+
temp_chunk_length = length
|
|
64
|
+
else:
|
|
65
|
+
temp_chunk_length += length
|
|
66
|
+
total_parts += 1 # for the last chunk
|
|
67
|
+
|
|
68
|
+
chunk: List[str] = []
|
|
69
|
+
chunk_length = 0
|
|
70
|
+
part = 1
|
|
71
|
+
|
|
72
|
+
for line, _, length in lines:
|
|
73
|
+
if chunk_length + length > MAX_EMBEDDING_LENGTH:
|
|
74
|
+
metadata = {
|
|
75
|
+
"title": post["title"],
|
|
76
|
+
"url": post["link"],
|
|
77
|
+
"date": (
|
|
78
|
+
post["date"].isoformat()
|
|
79
|
+
if isinstance(post["date"], pd.Timestamp)
|
|
80
|
+
else post["date"]
|
|
81
|
+
),
|
|
82
|
+
"total_parts": str(total_parts),
|
|
83
|
+
"part": str(part),
|
|
84
|
+
}
|
|
85
|
+
yield metadata, "\n".join(chunk)
|
|
86
|
+
chunk = []
|
|
87
|
+
chunk_length = 0
|
|
88
|
+
part += 1
|
|
89
|
+
chunk.append(line)
|
|
90
|
+
chunk_length += length
|
|
91
|
+
|
|
92
|
+
if chunk:
|
|
93
|
+
metadata = {
|
|
94
|
+
"title": post["title"],
|
|
95
|
+
"url": post["link"],
|
|
96
|
+
"date": (
|
|
97
|
+
post["date"].isoformat()
|
|
98
|
+
if isinstance(post["date"], pd.Timestamp)
|
|
99
|
+
else post["date"]
|
|
100
|
+
),
|
|
101
|
+
"total_parts": str(total_parts),
|
|
102
|
+
"part": str(part),
|
|
103
|
+
}
|
|
104
|
+
yield metadata, "\n".join(chunk)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def chunker(name: str) -> None:
|
|
108
|
+
"""
|
|
109
|
+
Process a JSONL file of blog posts, split them into chunks,
|
|
110
|
+
and save the results.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
name (str): The name of the file to process (without extension).
|
|
114
|
+
"""
|
|
115
|
+
sourcefile = f"data/{name}.jsonl"
|
|
116
|
+
outputfile = f"data/{name}_chunked.jsonl"
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
# Read the JSONL file line by line
|
|
120
|
+
with open(sourcefile, "r") as f:
|
|
121
|
+
data = [json.loads(line) for line in f]
|
|
122
|
+
|
|
123
|
+
blog_posts: pd.DataFrame = pd.DataFrame(data)
|
|
124
|
+
print(f"Splitting {len(blog_posts)} blog posts into chunks")
|
|
125
|
+
|
|
126
|
+
with open(outputfile, "w") as f:
|
|
127
|
+
for item in split_into_chunks(blog_posts):
|
|
128
|
+
f.write(json.dumps(item) + "\n")
|
|
129
|
+
|
|
130
|
+
except json.JSONDecodeError as e:
|
|
131
|
+
print(f"Error decoding JSON: {e}")
|
|
132
|
+
print(f"Error occurred at line {e.lineno}")
|
|
133
|
+
with open(sourcefile, "r") as f:
|
|
134
|
+
problematic_line = f.readlines()[e.lineno - 1]
|
|
135
|
+
print(f"Problematic line: {problematic_line}")
|
|
136
|
+
|
|
137
|
+
except Exception as e:
|
|
138
|
+
print(f"An error occurred: {e}")
|
raft/cli.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module contains the main CLI functionality for the RAFT project.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from raft import (
|
|
7
|
+
files_helper,
|
|
8
|
+
embeddings_helpers,
|
|
9
|
+
substack_embeddings,
|
|
10
|
+
generate_finetune,
|
|
11
|
+
oai_finetune,
|
|
12
|
+
memories,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def action_doc() -> str:
|
|
17
|
+
"""
|
|
18
|
+
Return a string describing available actions for the CLI.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
str: Description of available actions.
|
|
22
|
+
"""
|
|
23
|
+
return """
|
|
24
|
+
The following actions are available:
|
|
25
|
+
|
|
26
|
+
- interactive: Guided end-to-end session: sources, conversations, finetune.
|
|
27
|
+
- tweets: Build a dataset from tweets via ariadne interactive.
|
|
28
|
+
- fetch: Fetch the blog from Substack and store it in the data directory.
|
|
29
|
+
- chunk: Chunk the blog into 4096 token pieces and store them in /data.
|
|
30
|
+
- embed: Create embeddings for the chunks and store them.
|
|
31
|
+
- ft:gen: Generate finetune files for the blog.
|
|
32
|
+
- ft:run: Run the finetune job (OpenAI, or huggingface via opbdh).
|
|
33
|
+
- bench:setup: Setup the benchmark for the blog.
|
|
34
|
+
- ask: Ask a question about the blog content.
|
|
35
|
+
|
|
36
|
+
ft:run routes by --model: OpenAI-finetunable ids go to the OpenAI API,
|
|
37
|
+
anything else (an org/name huggingface id) is trained on a GPU pod via
|
|
38
|
+
opbdh; unknown flags after the name are forwarded verbatim to
|
|
39
|
+
`opbdh launch` (e.g. --vram-gb 48 --max-spend 5).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
cmds = [
|
|
44
|
+
"interactive",
|
|
45
|
+
"tweets",
|
|
46
|
+
"fetch",
|
|
47
|
+
"chunk",
|
|
48
|
+
"embed",
|
|
49
|
+
"ft:gen",
|
|
50
|
+
"ft:run",
|
|
51
|
+
"bench:setup",
|
|
52
|
+
"ask",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main() -> None:
|
|
57
|
+
"""
|
|
58
|
+
Main function to handle CLI arguments and execute corresponding actions.
|
|
59
|
+
"""
|
|
60
|
+
parser = argparse.ArgumentParser(
|
|
61
|
+
description="Run the raft command.",
|
|
62
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
63
|
+
epilog=action_doc(),
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"action",
|
|
67
|
+
help="The action to perform; see below for details.",
|
|
68
|
+
choices=cmds,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"name",
|
|
73
|
+
nargs="?",
|
|
74
|
+
default="",
|
|
75
|
+
help="The name of the dataset to process.",
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"--oai",
|
|
79
|
+
help="Only generate finetune or benchmark for openai \
|
|
80
|
+
(from existing generic file).",
|
|
81
|
+
default=False,
|
|
82
|
+
action="store_true",
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--generic",
|
|
86
|
+
help="Only generate generic finetune or benchmark file.",
|
|
87
|
+
default=False,
|
|
88
|
+
action="store_true",
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--question",
|
|
92
|
+
help="The question to ask about the blog content.",
|
|
93
|
+
default=None,
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"--no-useful-check",
|
|
97
|
+
action="store_true",
|
|
98
|
+
help="Skip usefulness check when summarizing memories",
|
|
99
|
+
)
|
|
100
|
+
parser.add_argument(
|
|
101
|
+
"--model",
|
|
102
|
+
help="Model to finetune (ft:run): an OpenAI id, or a \
|
|
103
|
+
huggingface org/name id trained via opbdh.",
|
|
104
|
+
default="",
|
|
105
|
+
)
|
|
106
|
+
parser.add_argument(
|
|
107
|
+
"--no-interactive",
|
|
108
|
+
action="store_true",
|
|
109
|
+
help="ft:run: never prompt; rely on flags and opbdh config.",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
args, extra = parser.parse_known_args()
|
|
113
|
+
|
|
114
|
+
if args.action not in ("ft:run",) and extra:
|
|
115
|
+
parser.error(f"unrecognized arguments: {' '.join(extra)}")
|
|
116
|
+
|
|
117
|
+
needs_name = args.action not in ("interactive", "tweets")
|
|
118
|
+
if needs_name and not args.name:
|
|
119
|
+
parser.error(f"the '{args.action}' action requires a dataset name")
|
|
120
|
+
|
|
121
|
+
if args.action == "interactive":
|
|
122
|
+
from .flows import run_interactive
|
|
123
|
+
|
|
124
|
+
run_interactive()
|
|
125
|
+
elif args.action == "tweets":
|
|
126
|
+
from .tweet_mode import run_tweet_mode
|
|
127
|
+
|
|
128
|
+
run_tweet_mode(args.name)
|
|
129
|
+
elif args.action == "fetch":
|
|
130
|
+
substack_embeddings.main(args.name)
|
|
131
|
+
elif args.action == "chunk":
|
|
132
|
+
files_helper.chunker(args.name)
|
|
133
|
+
elif args.action == "embed":
|
|
134
|
+
embeddings_helpers.store_grounding_embeddings(args.name)
|
|
135
|
+
elif args.action == "ft:gen":
|
|
136
|
+
if args.oai:
|
|
137
|
+
oai_finetune.create_openai_finetune_file(args.name)
|
|
138
|
+
elif args.generic:
|
|
139
|
+
generate_finetune.generate_finetune(args.name)
|
|
140
|
+
else:
|
|
141
|
+
generate_finetune.generate_finetune(args.name)
|
|
142
|
+
oai_finetune.create_openai_finetune_file(args.name)
|
|
143
|
+
elif args.action == "ft:run":
|
|
144
|
+
from .hf_finetune import is_openai_finetunable, run_hf_finetune
|
|
145
|
+
from .interactive import ask
|
|
146
|
+
|
|
147
|
+
model = args.model
|
|
148
|
+
if not model and not args.no_interactive:
|
|
149
|
+
model = ask(
|
|
150
|
+
"Model to finetune (OpenAI id or huggingface org/name)",
|
|
151
|
+
"gpt-4o-mini-2024-07-18",
|
|
152
|
+
)
|
|
153
|
+
if is_openai_finetunable(model):
|
|
154
|
+
if extra:
|
|
155
|
+
parser.error(
|
|
156
|
+
"opbdh flags only apply to huggingface models: "
|
|
157
|
+
f"{' '.join(extra)}"
|
|
158
|
+
)
|
|
159
|
+
oai_finetune.run_oai_finetune(args.name, model=model)
|
|
160
|
+
else:
|
|
161
|
+
run_hf_finetune(
|
|
162
|
+
args.name,
|
|
163
|
+
model,
|
|
164
|
+
opbdh_args=extra,
|
|
165
|
+
interactive=not args.no_interactive,
|
|
166
|
+
)
|
|
167
|
+
elif args.action == "bench:setup":
|
|
168
|
+
if args.oai:
|
|
169
|
+
oai_finetune.create_openai_finetune_file(args.name, "benchmark")
|
|
170
|
+
elif args.generic:
|
|
171
|
+
generate_finetune.generate_finetune(args.name)
|
|
172
|
+
else:
|
|
173
|
+
generate_finetune.generate_benchmark(args.name)
|
|
174
|
+
oai_finetune.create_openai_finetune_file(args.name, "benchmark")
|
|
175
|
+
elif args.action == "ask":
|
|
176
|
+
if args.question is None:
|
|
177
|
+
print("Please provide a question using the --question argument.")
|
|
178
|
+
else:
|
|
179
|
+
memory_manager = memories.MemoryManager(
|
|
180
|
+
args.name, {}
|
|
181
|
+
) # Empty metadata for now
|
|
182
|
+
answer = memory_manager.ask_question(args.question)
|
|
183
|
+
print(f"Answer: {answer}")
|
|
184
|
+
else:
|
|
185
|
+
print(f"Unknown action: {args.action}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
if __name__ == "__main__":
|
|
189
|
+
main()
|
raft/convo_structurer.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Turn conversation examples and text sources -- structured or not -- into
|
|
3
|
+
the datasets raft consumes:
|
|
4
|
+
|
|
5
|
+
- transcripts: data/{name}_transcript_{i}.json
|
|
6
|
+
{"participants": {"q": ..., "a": ...}, "date": ..., "url": ...,
|
|
7
|
+
"exchanges": [[question, answer], ...]}
|
|
8
|
+
- grounding corpus: data/{name}.jsonl, one {"title", "link", "date",
|
|
9
|
+
"content"} object per line (the input of `raft chunk`).
|
|
10
|
+
|
|
11
|
+
Unstructured files are converted with an LLM; structured ones (raft
|
|
12
|
+
transcripts, chat-message arrays, grounding jsonl) are recognised and
|
|
13
|
+
passed through.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
21
|
+
|
|
22
|
+
from openai import OpenAI
|
|
23
|
+
|
|
24
|
+
from . import hx
|
|
25
|
+
|
|
26
|
+
STRUCTURER_MODEL = os.environ.get("RAFT_LLM_MODEL", "gpt-4o")
|
|
27
|
+
|
|
28
|
+
# Keep single LLM calls comfortably inside the context window.
|
|
29
|
+
MAX_STRUCTURE_CHARS = 24000
|
|
30
|
+
|
|
31
|
+
STRUCTURER_SYSTEM_PROMPT = """\
|
|
32
|
+
You extract question/answer exchanges from a raw conversation record
|
|
33
|
+
(interview, chat log, podcast transcript, email thread...).
|
|
34
|
+
|
|
35
|
+
The target is the person being emulated: their utterances are answers,
|
|
36
|
+
everyone else's are questions. Merge consecutive utterances by the same
|
|
37
|
+
side. Drop utterances that are not part of a question/answer flow
|
|
38
|
+
(stage directions, ads, timestamps).
|
|
39
|
+
|
|
40
|
+
Reply with JSON only, in this exact shape:
|
|
41
|
+
{
|
|
42
|
+
"participants": {"q": "<questioner name>", "a": "<target name>"},
|
|
43
|
+
"date": "<ISO date if inferable, else null>",
|
|
44
|
+
"url": "<source url if present, else null>",
|
|
45
|
+
"exchanges": [["<question>", "<answer>"], ...]
|
|
46
|
+
}"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def transcript_path(name: str, index: int) -> str:
|
|
50
|
+
"""Return the path of transcript #index for a dataset name."""
|
|
51
|
+
return f"data/{name}_transcript_{index}.json"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def next_transcript_index(name: str) -> int:
|
|
55
|
+
"""Return the first unused transcript index for a dataset name."""
|
|
56
|
+
i = 1
|
|
57
|
+
while os.path.exists(transcript_path(name, i)):
|
|
58
|
+
i += 1
|
|
59
|
+
return i
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def write_transcript(
|
|
63
|
+
name: str,
|
|
64
|
+
participants: Dict[str, str],
|
|
65
|
+
date: str,
|
|
66
|
+
url: str,
|
|
67
|
+
exchanges: List[List[str]],
|
|
68
|
+
index: Optional[int] = None,
|
|
69
|
+
) -> str:
|
|
70
|
+
"""
|
|
71
|
+
Write a transcript file in the format generate_finetune expects.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
str: The path written.
|
|
75
|
+
"""
|
|
76
|
+
if index is None:
|
|
77
|
+
index = next_transcript_index(name)
|
|
78
|
+
path = transcript_path(name, index)
|
|
79
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
80
|
+
with open(path, "w") as f:
|
|
81
|
+
json.dump(
|
|
82
|
+
{
|
|
83
|
+
"participants": participants,
|
|
84
|
+
"date": date,
|
|
85
|
+
"url": url,
|
|
86
|
+
"exchanges": exchanges,
|
|
87
|
+
},
|
|
88
|
+
f,
|
|
89
|
+
indent=2,
|
|
90
|
+
)
|
|
91
|
+
return path
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def is_transcript(data: Any) -> bool:
|
|
95
|
+
"""Check whether parsed JSON already matches the transcript schema."""
|
|
96
|
+
return (
|
|
97
|
+
isinstance(data, dict)
|
|
98
|
+
and isinstance(data.get("exchanges"), list)
|
|
99
|
+
and isinstance(data.get("participants"), dict)
|
|
100
|
+
and {"q", "a"} <= set(data["participants"])
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_json_or_jsonl(raw: str) -> Any:
|
|
105
|
+
"""
|
|
106
|
+
Parse a string as JSON, falling back to JSONL (returning the list of
|
|
107
|
+
parsed lines). Returns None if it is neither.
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
return json.loads(raw)
|
|
111
|
+
except json.JSONDecodeError:
|
|
112
|
+
pass
|
|
113
|
+
try:
|
|
114
|
+
parsed = [json.loads(line) for line in raw.splitlines() if line.strip()]
|
|
115
|
+
except json.JSONDecodeError:
|
|
116
|
+
return None
|
|
117
|
+
return parsed or None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def is_message_array(value: Any) -> bool:
|
|
121
|
+
"""Check whether a value looks like a chat-message array."""
|
|
122
|
+
return (
|
|
123
|
+
isinstance(value, list)
|
|
124
|
+
and bool(value)
|
|
125
|
+
and all(isinstance(m, dict) for m in value)
|
|
126
|
+
and any("role" in m for m in value)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def find_message_arrays(data: Any) -> List[List[Dict[str, Any]]]:
|
|
131
|
+
"""
|
|
132
|
+
Collect every chat-message array in a parsed conversation file.
|
|
133
|
+
|
|
134
|
+
Handles a bare message array, {"messages": [...]}, ariadne's
|
|
135
|
+
{"conversations": [{"messages": [...]}, ...]}, and JSONL files whose
|
|
136
|
+
lines are any of the above.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
List[List[Dict[str, Any]]]: One entry per conversation found.
|
|
140
|
+
"""
|
|
141
|
+
if is_message_array(data):
|
|
142
|
+
return [data]
|
|
143
|
+
if isinstance(data, dict):
|
|
144
|
+
if is_message_array(data.get("messages")):
|
|
145
|
+
return [data["messages"]]
|
|
146
|
+
if isinstance(data.get("conversations"), list):
|
|
147
|
+
found = []
|
|
148
|
+
for convo in data["conversations"]:
|
|
149
|
+
found.extend(find_message_arrays(convo))
|
|
150
|
+
return found
|
|
151
|
+
return []
|
|
152
|
+
if isinstance(data, list):
|
|
153
|
+
found = []
|
|
154
|
+
for item in data:
|
|
155
|
+
found.extend(find_message_arrays(item))
|
|
156
|
+
return found
|
|
157
|
+
return []
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def messages_to_exchanges(
|
|
161
|
+
messages: List[Dict[str, Any]], target: str
|
|
162
|
+
) -> Tuple[List[List[str]], str]:
|
|
163
|
+
"""
|
|
164
|
+
Convert a chat-message array (role/content, optionally name) into
|
|
165
|
+
[question, answer] exchanges, treating the target's (or assistant's)
|
|
166
|
+
turns as answers.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Tuple[List[List[str]], str]: The exchanges and the most frequent
|
|
170
|
+
questioner name found (empty string if none).
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def is_answer(msg: Dict[str, Any]) -> bool:
|
|
174
|
+
if target and msg.get("name", "").lower() == target.lower():
|
|
175
|
+
return True
|
|
176
|
+
return msg.get("role") == "assistant"
|
|
177
|
+
|
|
178
|
+
exchanges: List[List[str]] = []
|
|
179
|
+
q_names: Dict[str, int] = {}
|
|
180
|
+
pending_q: List[str] = []
|
|
181
|
+
for msg in messages:
|
|
182
|
+
content = str(msg.get("content", "")).strip()
|
|
183
|
+
if not content or msg.get("role") == "system":
|
|
184
|
+
continue
|
|
185
|
+
if is_answer(msg):
|
|
186
|
+
if pending_q:
|
|
187
|
+
exchanges.append(["\n".join(pending_q), content])
|
|
188
|
+
pending_q = []
|
|
189
|
+
elif exchanges:
|
|
190
|
+
exchanges[-1][1] += f"\n{content}"
|
|
191
|
+
else:
|
|
192
|
+
pending_q.append(content)
|
|
193
|
+
q_name = msg.get("name", "")
|
|
194
|
+
if q_name:
|
|
195
|
+
q_names[q_name] = q_names.get(q_name, 0) + 1
|
|
196
|
+
|
|
197
|
+
questioner = max(q_names, key=lambda k: q_names[k]) if q_names else ""
|
|
198
|
+
return exchanges, questioner
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def structure_raw_conversation(raw_text: str, target: str) -> Dict[str, Any]:
|
|
202
|
+
"""
|
|
203
|
+
Use an LLM to turn an unstructured conversation into transcript JSON.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
raw_text (str): The raw conversation text.
|
|
207
|
+
target (str): Name of the person being emulated.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
Dict[str, Any]: participants/date/url/exchanges dict.
|
|
211
|
+
"""
|
|
212
|
+
client = OpenAI()
|
|
213
|
+
if len(raw_text) > MAX_STRUCTURE_CHARS:
|
|
214
|
+
hx.warn(
|
|
215
|
+
f"source is {len(raw_text)} chars; structuring the first "
|
|
216
|
+
f"{MAX_STRUCTURE_CHARS} -- split long files for full coverage"
|
|
217
|
+
)
|
|
218
|
+
raw_text = raw_text[:MAX_STRUCTURE_CHARS]
|
|
219
|
+
response = client.chat.completions.create(
|
|
220
|
+
model=STRUCTURER_MODEL,
|
|
221
|
+
response_format={"type": "json_object"},
|
|
222
|
+
messages=[
|
|
223
|
+
{"role": "system", "content": STRUCTURER_SYSTEM_PROMPT},
|
|
224
|
+
{
|
|
225
|
+
"role": "user",
|
|
226
|
+
"content": f"The target is: {target}\n\n---\n\n{raw_text}",
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
)
|
|
230
|
+
data = json.loads(response.choices[0].message.content or "{}")
|
|
231
|
+
if not isinstance(data.get("exchanges"), list) or not data["exchanges"]:
|
|
232
|
+
raise ValueError("the LLM could not extract any exchanges")
|
|
233
|
+
data.setdefault("participants", {})
|
|
234
|
+
data["participants"].setdefault("q", "Interviewer")
|
|
235
|
+
data["participants"].setdefault("a", target)
|
|
236
|
+
return data
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def import_conversation_file(name: str, path: str, target: str) -> str:
|
|
240
|
+
"""
|
|
241
|
+
Import one conversation file (structured or not) as a transcript.
|
|
242
|
+
|
|
243
|
+
Recognises: raft transcript JSON, chat-message arrays (bare list or
|
|
244
|
+
{"messages": [...]} / {"conversations": [...]}), anything else is
|
|
245
|
+
treated as raw text and structured with an LLM.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
str: The transcript path written.
|
|
249
|
+
"""
|
|
250
|
+
with open(path) as f:
|
|
251
|
+
raw = f.read()
|
|
252
|
+
|
|
253
|
+
data = parse_json_or_jsonl(raw) if path.endswith((".json", ".jsonl")) else None
|
|
254
|
+
today = datetime.now(timezone.utc).date().isoformat()
|
|
255
|
+
|
|
256
|
+
if is_transcript(data):
|
|
257
|
+
return write_transcript(
|
|
258
|
+
name,
|
|
259
|
+
data["participants"],
|
|
260
|
+
data.get("date") or today,
|
|
261
|
+
data.get("url") or path,
|
|
262
|
+
data["exchanges"],
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
conversations = find_message_arrays(data)
|
|
266
|
+
if conversations:
|
|
267
|
+
exchanges: List[List[str]] = []
|
|
268
|
+
questioners: Dict[str, int] = {}
|
|
269
|
+
for messages in conversations:
|
|
270
|
+
got, questioner = messages_to_exchanges(messages, target)
|
|
271
|
+
exchanges.extend(got)
|
|
272
|
+
if questioner:
|
|
273
|
+
questioners[questioner] = questioners.get(questioner, 0) + 1
|
|
274
|
+
if not exchanges:
|
|
275
|
+
raise ValueError(f"{path}: no question/answer exchanges found")
|
|
276
|
+
top = max(questioners, key=lambda k: questioners[k]) if questioners else ""
|
|
277
|
+
meta = data if isinstance(data, dict) else {}
|
|
278
|
+
return write_transcript(
|
|
279
|
+
name,
|
|
280
|
+
{"q": top or "Interviewer", "a": target},
|
|
281
|
+
meta.get("date") or today,
|
|
282
|
+
meta.get("url") or path,
|
|
283
|
+
exchanges,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
hx.step(f"{path}: unstructured, extracting exchanges with {STRUCTURER_MODEL}")
|
|
287
|
+
structured = structure_raw_conversation(raw, target)
|
|
288
|
+
return write_transcript(
|
|
289
|
+
name,
|
|
290
|
+
structured["participants"],
|
|
291
|
+
structured.get("date") or today,
|
|
292
|
+
structured.get("url") or path,
|
|
293
|
+
structured["exchanges"],
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def import_text_source_file(name: str, path: str) -> int:
|
|
298
|
+
"""
|
|
299
|
+
Append a text source (structured grounding jsonl or raw text) to the
|
|
300
|
+
grounding corpus data/{name}.jsonl.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
int: Number of documents appended.
|
|
304
|
+
"""
|
|
305
|
+
corpus_path = f"data/{name}.jsonl"
|
|
306
|
+
os.makedirs("data", exist_ok=True)
|
|
307
|
+
|
|
308
|
+
with open(path) as f:
|
|
309
|
+
raw = f.read()
|
|
310
|
+
|
|
311
|
+
records: List[Dict[str, Any]] = []
|
|
312
|
+
if path.endswith(".jsonl"):
|
|
313
|
+
try:
|
|
314
|
+
parsed = [json.loads(line) for line in raw.splitlines() if line.strip()]
|
|
315
|
+
if all(isinstance(p, dict) and "content" in p for p in parsed):
|
|
316
|
+
records = parsed
|
|
317
|
+
except json.JSONDecodeError:
|
|
318
|
+
records = []
|
|
319
|
+
|
|
320
|
+
if not records:
|
|
321
|
+
mtime = datetime.fromtimestamp(os.path.getmtime(path), timezone.utc)
|
|
322
|
+
title = re.sub(r"[_-]+", " ", os.path.splitext(os.path.basename(path))[0])
|
|
323
|
+
records = [
|
|
324
|
+
{
|
|
325
|
+
"title": title,
|
|
326
|
+
"link": path,
|
|
327
|
+
"date": mtime.date().isoformat(),
|
|
328
|
+
"content": raw,
|
|
329
|
+
}
|
|
330
|
+
]
|
|
331
|
+
|
|
332
|
+
with open(corpus_path, "a") as f:
|
|
333
|
+
for record in records:
|
|
334
|
+
record.setdefault("title", "untitled")
|
|
335
|
+
record.setdefault("link", path)
|
|
336
|
+
record.setdefault("date", datetime.now(timezone.utc).date().isoformat())
|
|
337
|
+
f.write(json.dumps(record) + "\n")
|
|
338
|
+
return len(records)
|