インタビューの音声を文字起こしすると、発話は時系列のまま平坦な一列で出てくる。このままだと「その発言は何に対する答えか」を機械的に取り出せないので、引用や集計の前に構造を復元する必要がある。
この記事はツール比較ではありません。ここで扱うのは、話者ラベル付きの文字起こし結果を前提に、そこから質問と回答の対応関係を復元する手順です。入力となる話者ラベル付きの原稿を用意する手段としては、インタビュー文字起こしツール(Video Transcriber AI)のようなサービスで TXT または CSV を書き出す方法もあれば、手元のモデルを使う方法もあります。どちらの場合でも、この後段の処理は必要になります。
なぜ「平坦な一列」では不足なのか
発話の列だけでは、次の 3 つが判定できない。
- その発言が自発的なものか、質問に引き出されたものか
- 複数の発話が同じ質問に対する答えなのか、別の話題なのか
- 引用するときに、どの質問を併記すれば文脈が保たれるか
とくに 3 つ目が実務に効く。同じ「高いと思います」という発言でも、質問が「競合と比べてどうですか」なのか「社内での評判はどうですか」なのかで、記事に載せたときの意味が変わる。
話者推定のヒューリズムと限界
今回の実装では、疑問符で終わる発話が最も多い話者を質問側とみなす。単純だが、一対一のインタビューではほぼ動く。
ただしこれは明確な仮定の上に成り立っている。両者が質問を投げ合う形式(パネルディスカッション、相互インタビュー)では破綻するし、疑問符を付けない話し方をする人もいる。そのため実装では --interviewer で明示的に指定できるようにしてある。推定が当たっているかは、後述のカウンタで確認できる。
実装
入力は Speaker: text 形式のテキストを想定しています。全角コロンにも対応させています。
"""Rebuild question and answer pairs from a speaker-labelled transcript.
Reads a TXT export written as "Speaker: text" lines, identifies which speaker
asks the questions, then pairs every question with the answers that follow it.
Usage:
python qa_pairs.py transcript.txt --markdown out.md
"""
from __future__ import annotations
import argparse
import re
import sys
TURN_RE = re.compile(r"^\s*[\[\(]?(?P<sp>[^\s::]{1,24})[\]\)]?\s*[::]\s*(?P<tx>.+)$")
QUESTION_END = ("?", "?")
def load(path):
turns, current = [], None
with open(path, encoding="utf-8") as fh:
for raw in fh:
line = raw.strip()
if not line:
continue
match = TURN_RE.match(line)
if match:
if current:
turns.append(current)
current = {"speaker": match.group("sp"), "text": match.group("tx")}
elif current:
current["text"] += " " + line
if current:
turns.append(current)
return turns
def guess_interviewer(turns):
"""The speaker whose turns most often end with a question mark asks the questions.
This is a heuristic. It fails when both people ask questions, and it says
nothing about turns where the same speaker never punctuates a question.
"""
counts = {}
for turn in turns:
counts.setdefault(turn["speaker"], 0)
if turn["text"].rstrip().endswith(QUESTION_END):
counts[turn["speaker"]] += 1
if not counts:
return None
best = max(counts.items(), key=lambda kv: (kv[1], -list(counts).index(kv[0])))
return best[0] if best[1] else None
def build_pairs(turns, interviewer):
pairs = []
for turn in turns:
if turn["speaker"] == interviewer:
pairs.append({"question": turn, "answers": []})
elif pairs:
pairs[-1]["answers"].append(turn)
else:
pairs.append({"question": None, "answers": [turn]})
return pairs
def stats(pairs):
finished = [p for p in pairs if p["question"] and p["answers"]]
lengths = [len(a["text"].split()) for p in finished for a in p["answers"]]
return {
"pairs": len(finished),
"orphan_questions": len([p for p in pairs if p["question"] and not p["answers"]]),
"orphan_answers": len([p for p in pairs if not p["question"]]),
"avg_answer_words": round(sum(lengths) / len(lengths), 1) if lengths else 0.0,
"longest_answer": max(lengths) if lengths else 0,
}
def render(pairs):
lines = []
index = 0
for pair in pairs:
if not pair["question"]:
lines.append("> %s\n" % " ".join(a["text"] for a in pair["answers"]))
continue
index += 1
lines.append("**Q%d. %s** (%s)\n" % (index, pair["question"]["text"], pair["question"]["speaker"]))
for answer in pair["answers"]:
lines.append("> %s (%s)\n" % (answer["text"], answer["speaker"]))
return "\n".join(lines)
def main(argv=None):
parser = argparse.ArgumentParser()
parser.add_argument("path")
parser.add_argument("--interviewer", default=None)
parser.add_argument("--markdown", default=None)
args = parser.parse_args(argv)
turns = load(args.path)
if not turns:
print("no turns parsed; expected lines shaped like 'Speaker: text'", file=sys.stderr)
return 1
interviewer = args.interviewer or guess_interviewer(turns)
if interviewer is None:
print("could not identify the interviewer; pass --interviewer", file=sys.stderr)
return 1
pairs = build_pairs(turns, interviewer)
report = stats(pairs)
print("speakers : %s" % ", ".join(sorted({t['speaker'] for t in turns})))
print("interviewer: %s (auto)" % interviewer if not args.interviewer else "interviewer: %s" % interviewer)
print("qa pairs : %d" % report["pairs"])
print("questions without an answer : %d" % report["orphan_questions"])
print("answers without a question : %d" % report["orphan_answers"])
print("avg answer length : %s words" % report["avg_answer_words"])
print("longest answer : %s words" % report["longest_answer"])
if args.markdown:
with open(args.markdown, "w", encoding="utf-8") as fh:
fh.write(render(pairs))
print("wrote %s" % args.markdown)
return 0
if __name__ == "__main__":
raise SystemExit(main())
interviewer の推定は endswith だけで判定しています。句点の有無や話し言葉特有の語尾には依存させない方針です。今回はこれで十分ですが、形式の揺れが大きい場合は --interviewer で固定してください。
実行結果
実際に 3 組のやり取りを含むファイルで実行すると次のようになります。
speakers : インタビュアー, 回答者
interviewer: インタビュアー (auto)
qa pairs : 3
questions without an answer : 0
answers without a question : 0
avg answer length : 3.2 words
longest answer : 7 words
ここで見るべきなのは questions without an answer と answers without a question です。
前者が多いなら質問側の推定が間違っている可能性が高い。後者が多いなら、質問と答えが一対一で対応していない(答えが長く区切りがない、あるいは序盤に質問なしの発言が続いている)ことを示します。この 2 つの値が 0 に近ければ、推定と構造化はうまくいっていると判断してよい。
生成される Markdown は次の形になります。引用ブロックが回答なので、そのまま議事メモや記事の下書きに貼れます。
**Q1. 今の料金体系について教えていただけますか?** (インタビュアー)
> 定価は 1 席あたり 15 ドルですが、実際にはほとんどのチームが 50 ドル払っています。 (回答者)
日本語の語数カウントでハマった点
avg answer length は len(text.split()) で数えている。英語の発話なら単語数として機能するが、日本語は空白で区切らないため、これは文の長さではなく文の数の代理にしかならない。実測でも 1 文が 3.2 words と出ており、文字数や文数で評価したいなら len(text) や句点の数え上げに変える必要がある。この値を絶対的な指標として使わないことが前提です。
できないこと
この手順は同時発話を扱わない。話者ラベルが「誰の発話か」を保証してくれるのは、話者が重なっていない区間だけです。重なりのある区間でラベル自体が怪しい場合は、この構造化を信じずに原音で確認する。
また、見出しは自分の手で付けていない。構造を得るところまでがこのコードの範囲で、トピックの抽出や要約は別の工程です。ここを一緒にやると、誤った話者推定がそのまま要約に混入して検証が難しくなる。
まとめ
文字起こしの価値は文章の量ではなく、質問と回答の対応関係が取り出せる状態になっているかで決まる。その前処理をスクリプトにしておくと、調査の記録を読む順番が変わる。
書き出し形式は TXT のほか CSV も選べます。相互インタビューのように話者が入れ替わる素材では推定が効かないので、手元の原稿がどういう形式かを確認してから インタビュー文字起こしツール の書き出し設定を選ぶのがよい。入力形式が違うだけで後段の手順は同じです。


