The whole flow
This manual moves through the 11 stages below in order. For each stage it shows what to decide and what was decided, applied to one example: matching Word documents against an in-house style list of terms and producing a list of candidates to fix.
- Idea
- Problem definition
- Decomposition
- Data shape
- Algorithm
- Instructions for Copilot
- Choosing a runtime
- Running
- Testing and verification
- Operation and automation
- Maintenance
The documents and the term list in the example are fictional. The code was run locally with Python 3.12.10 and python-docx 1.2.0, and the output is shown as is (2026-09-12).
1. Idea — find the repeated work
The idea starts from finding the part of a weekly manual task that a rule can decide. In the example, drafts of proposals and web articles are checked by eye against an in-house style list: terms to avoid, terms to rephrase and spelling variants. With many documents, things get missed and it takes time.
Four questions decide whether the task suits automation.
| Aspect | Question | Answer in the example |
|---|---|---|
| Frequency | Is the same work repeated? | Every week, across several documents |
| Rule | Can the rule be written down? | Does a sentence contain a term from the list? |
| Input | Is the input available as data? | Word (.docx) files |
| Checking | Can a person check the result? | A person reviews the candidate list and decides what to change |
All four hold, so the design is: Python does the matching and people only make the decisions. Whether a term is actually inappropriate in its context is not left to the machine.
Reading and fixing a single document can be done with Copilot in Word (Word in the practical edition). Python is used here to check many documents against the same list with the same criteria every time, and to keep the result as a table.
2. Problem definition — write down input, output and what counts as a pass
Before building anything, write on one page what goes in, what comes out, what counts as a pass and what is out of scope. Asking Copilot while this is vague leads to more rereading and rework.
| Item | Decision |
|---|---|
| Input | The .docx files in a folder, and the term list style_terms.csv (columns: term, category, action) |
| Output | A CSV file (columns: file, where, term, action, sentence) and a count per file |
| Pass | Every sentence containing a listed term is reported, from paragraphs and table cells alike, including full-width versus half-width variants (for example No.1 and No.1) |
| Allowed | Candidates that need no change may appear (a person removes them from the list) |
| Out of scope | Judging meaning or context, fixing documents automatically, and text in headers, footers and text boxes (for version 2) |
Reporting everything is the pass condition, and extra candidates are allowed, because a miss causes more trouble later. Decide which to favour for each task before building.
3. Decomposition — split into small steps and name them
Split the task into small steps that can each be checked on their own, and give each a function name. Named steps line up the instructions to Copilot with the units you test.
| Order | Step | Function | Input → output |
|---|---|---|---|
| 1 | Read the term list | load_terms | CSV path → list of Term |
| 2 | Take text out of a document in order | iter_blocks | document → (place, text) pairs |
| 3 | Split into sentences | split_sentences | text → list of sentences |
| 4 | Even out spelling variants | normalize | text → normalized text |
| 5 | Look for terms | find_hits | sentence and terms → list of Hit |
| 6 | Check a whole folder | check_folder | folder → list of Hit |
| 7 | Write the result | write_csv | list of Hit → CSV |
A good split is one where each step can be checked alone with a small input. Steps 4 (normalize) and 5 (find_hits) can be tested without opening any document, so they are the core of the tests in stage 9.
4. Data shape — decide what is passed between steps
Decide the shape of the values passed between steps. In the example, one row of the term list is a Term and one finding is a Hit. A Python dataclass fixes the names and number of fields, which makes typos easier to catch. According to the Python documentation, setting frozen to true makes assigning to fields raise an exception after the object is created.
| Shape | Fields | Example |
|---|---|---|
| Term | term, category, action, key (the term normalized for matching) | No.1 / ranking claim / state the survey source, date and scope / no.1 |
| Hit | file, where, term, action, sentence | doc_a.docx / paragraph 6 / No.1 / … / 利用者満足度 No.1(参考 1)。 |
Text taken from a document is paired with its place (paragraph 5, table 1 row 2 col 2 and so on), so that whoever reads the list can find the spot to fix in the original document.
According to the python-docx documentation, Document.paragraphs returns the paragraphs in document order, and Document.tables returns only tables at the top level of the document; a table nested inside a table cell is not included. The example uses these two, so nested tables are out of scope (see Word × Python for reading nested tables).
5. Algorithm — decide the order and the edge cases
- Read the term list and turn each term into its normalized form (key)
- Open the .docx files in the folder in name order, skipping files that start with "~$", which Word creates while a document is open
- Take the paragraphs in order, then the table cells in order
- Split into sentences at sentence-ending marks (。!?!?)
- Normalize each sentence and check whether it contains each term's key
- Collect what is found as Hit values and write them to CSV
Spelling variants are evened out with Unicode normalization form NFKC. The Python unicodedata documentation describes NFKC as one of the normal forms based on compatibility equivalence. Full-width "No.1" becomes "No.1" under NFKC (checked by a test in stage 9). Upper and lower case are evened out with casefold.
The regular expression (?<=[。!?!?]) splits right after a sentence-ending mark, using a lookbehind. The Python re documentation says the pattern inside a lookbehind must only match strings of some fixed length; a set of single characters meets that condition.
Edge case: "No.10" contains "No.1", so it becomes a candidate. That is an allowed extra under stage 2, so it is reported and a person removes it. Adding conditions on the surrounding characters would cut extras but could create misses, so check any such change against the pass condition first. If the term list grows a lot, measure the run time before changing the method.
6. Instructions for Copilot — hand over what you decided
Write the input, output, pass condition and function split decided so far straight into the instructions. The same wording works in Copilot Chat in Microsoft 365 and in GitHub Copilot.
Write a script with Python 3.12 and python-docx that checks the .docx files in a folder against our style list. Input: a folder path and a term-list CSV (UTF-8 with BOM, columns term, category, action). Output: a CSV (UTF-8 with BOM, columns file, where, term, action, sentence) and a count per file. Processing: take text from paragraphs and table cells, split it into sentences at 。!?!?, normalize with NFKC and casefold, then find sentences containing a term. Skip files that start with ~$. Functions: load_terms, iter_blocks, split_sentences, normalize, find_hits, check_folder, write_csv, with dataclasses Term and Hit for the values passed between them. Constraints: only read the original documents, never modify them. No network access. Use only the standard library and python-docx. Also write unittest tests for normalize and find_hits (full-width No.1 matches No.1; a rewritten sentence has no hit).
Check the code you receive in this order: (1) do the functions and arguments it uses exist in the official documentation (compare with the python-docx API docs); (2) does anything write to the input files; (3) run it on a few samples and compare with a count done by hand (stage 9). Do not use it on work documents until these checks are done. Instruction patterns are covered in more detail on From Instructions to Code.
7. Choosing a runtime — decide where it runs
| Option | Suits | Usable here? |
|---|---|---|
| A virtual environment (venv) on your PC | Documents are local or in a synced folder; trying it alone first | Yes (used here) |
| Python in Excel | Analysing data inside an Excel workbook | No. The code runs in the Microsoft Cloud and cannot reach files on your PC or the network |
| Azure Functions | Running on a schedule, sharing with a team | Yes (stage 10). Documents are fetched from SharePoint with Microsoft Graph |
For Python in Excel, Microsoft's documentation states that the Python code has no access to your computer, devices or account, and no network access, so it does not suit this example, which reads Word files (see Choosing Where Code Runs for the comparison).
Locally, create a virtual environment per project and install libraries into it. According to the Python documentation, a virtual environment is created by running the venv module and activated in PowerShell with Scripts\Activate.ps1, which may require setting the execution policy. Record the installed versions in requirements.txt with pip freeze.
Run in PowerShell from the project folder
python -m venv .venv
.venv\Scripts\Activate.ps1
python -m pip install python-docx==1.2.0
python -m pip freeze > requirements.txt8. Running — the finished program and its output
This is the finished program. The output from running it on three sample documents (doc_a.docx, doc_b.docx and e2e_doc_c.docx) with the term list is shown under Output below.
check_terms.py
"""Check .docx files in a folder against a style list and write the hits to a CSV file.
usage: python check_terms.py DOCS_FOLDER TERMS_CSV OUT_CSV
"""
import csv
import re
import sys
import unicodedata
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from docx import Document
SENTENCE_END = re.compile(r"(?<=[。!?!?])")
@dataclass(frozen=True)
class Term:
term: str
category: str
action: str
key: str
@dataclass(frozen=True)
class Hit:
file: str
where: str
term: str
action: str
sentence: str
def normalize(text):
# NFKC turns full-width letters and digits into their usual forms; casefold ignores case
return unicodedata.normalize("NFKC", text).casefold()
def load_terms(path):
with open(path, encoding="utf-8-sig", newline="") as f:
return [Term(r["term"], r["category"], r["action"], normalize(r["term"]))
for r in csv.DictReader(f) if r["term"].strip()]
def iter_blocks(doc):
for i, p in enumerate(doc.paragraphs, 1):
yield "paragraph %d" % i, p.text
for ti, table in enumerate(doc.tables, 1):
for ri, row in enumerate(table.rows, 1):
for ci, cell in enumerate(row.cells, 1):
yield "table %d row %d col %d" % (ti, ri, ci), cell.text
def split_sentences(text):
return [s.strip() for s in SENTENCE_END.split(text) if s.strip()]
def find_hits(name, where, sentence, terms):
key = normalize(sentence)
return [Hit(name, where, t.term, t.action, sentence) for t in terms if t.key in key]
def check_folder(folder, terms):
hits = []
for path in sorted(Path(folder).glob("*.docx")):
if path.name.startswith("~$"):
continue # lock files that Word leaves while a document is open
for where, text in iter_blocks(Document(path)):
for sentence in split_sentences(text):
hits += find_hits(path.name, where, sentence, terms)
return hits
def write_csv(hits, out):
with open(out, "w", encoding="utf-8-sig", newline="") as f:
w = csv.writer(f)
w.writerow(["file", "where", "term", "action", "sentence"])
w.writerows([h.file, h.where, h.term, h.action, h.sentence] for h in hits)
def main(folder, terms_csv, out_csv):
hits = check_folder(folder, load_terms(terms_csv))
write_csv(hits, out_csv)
print("hits:", len(hits))
for name, n in sorted(Counter(h.file for h in hits).items()):
print(" ", name, n)
for h in hits[:8]:
print(" %s | %s | %s -> %s" % (h.file, h.where, h.term, h.action))
if __name__ == "__main__":
main(*(sys.argv[1:] or ["samples", "samples/style_terms.csv", "out_hits.csv"]))Output(2026-09-12)
hits: 11
doc_a.docx 7
e2e_doc_c.docx 4
doc_a.docx | paragraph 5 | 最高 -> 根拠を確認する
doc_a.docx | paragraph 5 | 業界初 -> 根拠を確認する
doc_a.docx | paragraph 5 | 絶対 -> 言い換える
doc_a.docx | paragraph 6 | No.1 -> 調査の出典・時点・範囲を併記する
doc_a.docx | paragraph 7 | 下さい -> 「ください」に統一
doc_a.docx | paragraph 7 | 問合せ -> 「問い合わせ」に統一
doc_a.docx | table 1 row 2 col 2 | 完全無料 -> 条件を併記する
e2e_doc_c.docx | paragraph 2 | No.1 -> 調査の出典・時点・範囲を併記するReading the output: there are 11 candidates in all. The 7 in doc_a.docx match a count done by hand (stage 9). doc_b.docx has none, so it does not appear in the per-file counts. The 4 in e2e_doc_c.docx include the full-width "No.1", the allowed extra "No.10", "唯一" inside a table cell, and the variant spelling "出来る".
9. Testing and verification — check that it runs and that it is right, separately
Running without errors and being right (reporting what should be reported, and nothing that should not) are checked separately, in three ways.
- Unit tests: try normalize, split_sentences and find_hits on small inputs without opening documents
- Known-answer test: compare the program's count with a count done by hand; doc_a.docx has 7 by hand
- Edge-case test: a sample made on purpose (e2e_doc_c.docx) with full-width characters, a term inside a table cell, and "No.10" as an allowed extra
With Python's unittest, tests are methods whose names start with test, in a class that subclasses unittest.TestCase (per the Python documentation).
test_check_terms.py (placed next to check_terms.py)
"""Tests for check_terms.py (run: python test_check_terms.py)."""
import sys
import unittest
from check_terms import Term, check_folder, find_hits, load_terms, normalize, split_sentences
TERMS = [Term("No.1", "順位の表現", "出典を併記する", normalize("No.1")),
Term("下さい", "表記ゆれ", "「ください」に統一", normalize("下さい"))]
class CheckTermsTest(unittest.TestCase):
def test_full_width_matches(self):
hits = find_hits("a.docx", "paragraph 1", "満足度No.1です。", TERMS)
self.assertEqual([h.term for h in hits], ["No.1"])
def test_rewritten_sentence_has_no_hit(self):
self.assertEqual(find_hits("a.docx", "paragraph 1", "ご連絡ください。", TERMS), [])
def test_sentences_are_split_after_the_mark(self):
self.assertEqual(split_sentences("一文目。二文目!三文目"), ["一文目。", "二文目!", "三文目"])
def test_known_false_positive_stays_visible(self):
# "No.10" contains "No.1". It is reported on purpose; a person removes it from the list.
self.assertEqual(len(find_hits("a.docx", "paragraph 1", "No.10 を参照。", TERMS)), 1)
def test_known_answer_on_sample_document(self):
hits = check_folder("samples", load_terms("samples/style_terms.csv"))
self.assertEqual(sum(1 for h in hits if h.file == "doc_a.docx"), 7)
def test_table_cell_is_checked(self):
hits = check_folder("samples", load_terms("samples/style_terms.csv"))
self.assertTrue(any(h.file == "e2e_doc_c.docx" and h.where.startswith("table") and h.term == "唯一"
for h in hits))
if __name__ == "__main__":
suite = unittest.defaultTestLoader.loadTestsFromTestCase(CheckTermsTest)
result = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite)
sys.exit(0 if result.wasSuccessful() else 1)Output(2026-09-12)
test_full_width_matches (__main__.CheckTermsTest.test_full_width_matches) ... ok
test_known_answer_on_sample_document (__main__.CheckTermsTest.test_known_answer_on_sample_document) ... ok
test_known_false_positive_stays_visible (__main__.CheckTermsTest.test_known_false_positive_stays_visible) ... ok
test_rewritten_sentence_has_no_hit (__main__.CheckTermsTest.test_rewritten_sentence_has_no_hit) ... ok
test_sentences_are_split_after_the_mark (__main__.CheckTermsTest.test_sentences_are_split_after_the_mark) ... ok
test_table_cell_is_checked (__main__.CheckTermsTest.test_table_cell_is_checked) ... ok
----------------------------------------------------------------------
Ran 6 tests in 0.114s
OKRun the tests every time the term list or the program changes, and whenever a library version is raised.
Remaining limits: text in headers, footers and text boxes is not read (out of scope). According to the python-docx documentation, paragraphs inside revision marks (tracked insertions and deletions) do not appear in Document.paragraphs, so check documents after the changes have been accepted.
10. Operation and automation — run on a schedule and keep a record
Once local runs give stable results, run it on a schedule. On a local Windows PC, register it with Task Scheduler. According to the schtasks create documentation, a weekly schedule requires /sc weekly, days of the week can be set with /d, the start time /st uses the 24-hour HH:mm format, and /tr names the program or command to run.
Run run.cmd (which calls check_terms.py with the virtual environment's python) every Monday at 08:00
schtasks /create /sc weekly /d MON /st 08:00 /tn "TermCheck" /tr "C:\tools\term-check\run.cmd"To share it with a team, or when the documents are in SharePoint, run it with an Azure Functions timer and fetch documents with Microsoft Graph. According to the Azure Functions documentation, the schedule is an NCRONTAB expression ({second} {minute} {hour} {day} {month} {day-of-week}) and the default time zone is UTC; its example "0 30 9 * * 1-5" means 9:30 AM every weekday.
function_app.py (skeleton started by the timer)
"""Run the term check at 9:30 every weekday (Azure Functions, Python v2 model, timer trigger)."""
import logging
import os
import azure.functions as func
app = func.FunctionApp()
# {second} {minute} {hour} {day} {month} {day-of-week}. The default time zone is UTC;
# the Azure Functions docs describe the WEBSITE_TIME_ZONE app setting for other time zones.
@app.timer_trigger(schedule="0 30 9 * * 1-5", arg_name="timer", run_on_startup=False)
def weekday_term_check(timer: func.TimerRequest) -> None:
if timer.past_due:
logging.warning("The timer is past due")
folder = os.environ.get("TERM_CHECK_FOLDER", "")
logging.info("term check started (folder setting: %s)", folder or "not set")
# Fetch the changed documents with Microsoft Graph and call check_folder() here
# (see "Check only documents that changed" in the workflow recipes).Each run should record when it ran, how many documents it read and how many candidates it found. The logging.basicConfig(filename=..., level=logging.INFO) form from the Python logging documentation writes this to a file. Keep document text and personal information out of the record; file names and counts are enough. For sending the result, see Teams × Python.
11. Maintenance — watch what changes and rerun the tests
| What changes | What happens | Response |
|---|---|---|
| The term list | Counts change as terms are added or removed | Keep the list under version control (for example SharePoint version history) and rerun the stage 9 tests after each change |
| The python-docx version | Reading behaviour can change | Pin the version in requirements.txt; run the tests before raising it |
| How documents are written | More nested tables or text boxes | List what is out of scope and handle it in version 2 |
| The owner | The person who built it moves on | Keep the problem definition (stage 2), the tests and the procedure next to the program |
This section's automatic collection watches python-docx releases on GitHub every day. When a release changes how the library is used, a tip is added to the Word × Python page.
Set a review date in advance (for example every six months). At review, look at how many candidates people removed from the list and adjust the term list and the rules accordingly.