解説一覧

ResearchGo

まもなく公開

CIBES Research Centreのためのプラットフォームで、学術論文を投稿から出版まで扱います。開発は完了しており、公開前の最終確認中です。

担当
システム設計と開発
制作年
2026
使用技術
Next.js · Supabase (EU) · Python · FastAPI · GROBID · OpenAlex · Stripe
AUTHOR: PDF OR WORDNEXT.JS + SUPABASEPYTHON WORKER: FASTAPI + GROBIDVERSIONS: ENCRYPTEDTRACK CHANGES, REFERENCE CHECKJOURNAL MATCH: OPENALEXSIGN INSIGNED JOBPARSED TEXTCOMPAREEMBEDDINGALL DATA IN THE EURULES ONLY, NO LANGUAGE MODELTEXT STAYS ON OUR WORKER

横にスクロールすると、図の全体を見られます。

アーキテクチャUPLOAD → PARSE ON ARRIVAL → ENCRYPTED VERSIONS → EDITOR TOOLS

課題

学術論文の投稿は、メールのスレッドとスプレッドシートで回っています。関わる人が2人以上になるとすぐに、版、査読者のコメント、学術誌の要件がずれていきます。編集者は「これはどの版か」「何が変わったか」「参考文献に問題はないか」「どの学術誌が合うか」という同じ質問に、毎回手作業で答えています。

どう作られているか

Supabase上のNext.jsアプリで、データはすべてEU内に保管します。著者はORCIDかメールアドレスでログインし、原稿をPDFかWordでアップロードします。原稿が届くとPythonのワーカー(FastAPI)が解析し(PDFはGROBIDで処理)、構造化したテキストを、署名とタイムスタンプ付きのコールバックで返します。このコールバックは、偽造されたジョブやリプレイされたジョブを拒否します。解析が成功するたびに新しい版として暗号化して保存しますが、それはテキストが実際に変わった場合に限ります。編集者は任意の2つの版をWord風の変更履歴で比較し、本文中の引用を参考文献リストと照合し、意味の近さで順位づけされた学術誌の候補を受け取れます。候補は、自前のワーカーで計算したEmbeddingと、OpenAlexの学術誌データをもとにしています。決済はStripeです。

重要だった判断

  • ルールで済むところに言語モデルは使わない

    変更履歴と参考文献のチェックは決定的に動くコードで、AIは関わっていません。編集者はdiffを信頼できなければなりませんし、ルールなら毎回同じ答えを返します。

  • 原稿は自分たちのサーバーから出さない

    学術誌のマッチングでは自前のワーカーで論文をEmbeddingにするので、原稿のテキストが第三者に渡ることはありません。順位は純粋に類似度で決まります。インデックス、オープンアクセス、費用の情報はラベルとして表示するだけで、順番は変えません。

  • すべてのジョブに署名する

    Webアプリとワーカーの間でやりとりするジョブには、HMAC署名とタイムスタンプが付いています。偽造されたジョブやリプレイされたジョブは拒否され、ワーカーがジョブの途中で落ちても、その原稿の順番は失われません。

リポジトリのコード

apps/web/lib/versions/diff.ts原稿の2つの版の変更履歴を、言語モデルなしで出す
function diffParagraphPair(before: string, after: string): DiffSegment[] {
  return diffWordsWithSpace(before, after).map((part) => ({1
    op: part.added ? "ins" : part.removed ? "del" : "equal",
    text: part.value,
  }));
}
 
export function diffManuscripts(before: string, after: string): DiffResult {
  const a = splitParagraphs(before);
  const b = splitParagraphs(after);
  // …
  const parts = diffArrays(a, b);2
  let i = 0;
  while (i < parts.length) {
    const part = parts[i];
    if (!part) break;
    if (!part.added && !part.removed) {
      // …
      i++;
      continue;
    }
    const next = parts[i + 1];
    const paired = Boolean(part.removed && next?.added);3
    const removedList = part.removed ? part.value : [];
    const addedList = part.added ? part.value : paired && next ? next.value : [];
 
    const pairs = Math.min(removedList.length, addedList.length);
    for (let k = 0; k < pairs; k++) {
      const segments = diffParagraphPair(removedList[k] as string, addedList[k] as string);
      paragraphs.push({ kind: "changed", segments });
      // …
    }
    // …
    i += paired ? 2 : 1;
  }
 
  return { paragraphs, summary };
}
  1. 2行目. 変更された段落の中では、diffを単語単位で取り、空白も保ちます。そのため、Wordの変更履歴のように読めます。

  2. 12行目. 最初のパス:段落全体に対して最長共通部分列のdiffを取り、残った部分、追加された部分、削除された部分を見つけます。

  3. 23行目. 削除のすぐ後に追加が続く場合は、別々の2つの変更ではなく、1つの段落の編集として扱います。

今の状況

開発は完了し、最終確認中です。確認が終わり次第、公開します。

次の解説Ürünereklam